From b89b5d7c9447a6a7f479ef3383a4312df90a94dc Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 14 May 2026 12:12:16 +0200 Subject: [PATCH] refactor(app-updater): split AppUpdater.tsx into focused modules (#681) Extract the GitHub release probe, download/relaunch state and the markdown changelog renderer out of AppUpdater.tsx: - utils/appUpdaterHelpers.ts isNewer, fmtBytes, pickAsset, types - hooks/useAppUpdater.ts release probe + download/relaunch handlers - components/appUpdater/Changelog.tsx Pure code-move, no behaviour change. The AppShell call site is unchanged. --- src/components/AppUpdater.tsx | 288 +----------------------- src/components/appUpdater/Changelog.tsx | 38 ++++ src/hooks/useAppUpdater.ts | 211 +++++++++++++++++ src/utils/appUpdaterHelpers.ts | 53 +++++ 4 files changed, 312 insertions(+), 278 deletions(-) create mode 100644 src/components/appUpdater/Changelog.tsx create mode 100644 src/hooks/useAppUpdater.ts create mode 100644 src/utils/appUpdaterHelpers.ts diff --git a/src/components/AppUpdater.tsx b/src/components/AppUpdater.tsx index 5db23032..b0f111f0 100644 --- a/src/components/AppUpdater.tsx +++ b/src/components/AppUpdater.tsx @@ -1,291 +1,23 @@ -import React, { useEffect, useState, useRef } from 'react'; import { createPortal } from 'react-dom'; import { open } from '@tauri-apps/plugin-shell'; -import { listen } from '@tauri-apps/api/event'; -import { dirname } from '@tauri-apps/api/path'; -import { invoke } from '@tauri-apps/api/core'; import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { version as currentVersion } from '../../package.json'; -import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; - -const SKIP_KEY = 'psysonic_skipped_update_version'; - -// Semver comparison: returns true if `a` is newer than `b` -function isNewer(a: string, b: string): boolean { - const pa = a.replace(/^[^0-9]*/, '').split('.').map(Number); - const pb = b.replace(/^[^0-9]*/, '').split('.').map(Number); - for (let i = 0; i < 3; i++) { - if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true; - if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false; - } - return false; -} - -function fmtBytes(n: number): string { - if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; - return `${(n / 1024 / 1024).toFixed(1)} MB`; -} - -// Minimal inline-markdown renderer (bold, italic, code) -// IMPORTANT: regex must have NO nested capture groups — split() includes captured -// groups in the result, and nested groups produce undefined entries that crash on .startsWith() -function renderInline(text: string): React.ReactNode[] { - const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); - return parts.map((part, i) => { - if (!part) return null; - if (part.startsWith('**') && part.endsWith('**')) - return {part.slice(2, -2)}; - if (part.startsWith('*') && part.endsWith('*')) - return {part.slice(1, -1)}; - if (part.startsWith('`') && part.endsWith('`')) - return {part.slice(1, -1)}; - return part; - }); -} - -function renderChangelog(body: string) { - return body.split('\n').map((line, i) => { - if (line.startsWith('### ')) - return
{renderInline(line.slice(4))}
; - if (line.startsWith('#### ')) - return
{renderInline(line.slice(5))}
; - if (line.startsWith('## ')) - return null; // skip nested release headers in body - if (line.startsWith('- ')) - return
{renderInline(line.slice(2))}
; - if (line.trim() === '') return null; - return
{renderInline(line)}
; - }); -} - -interface GithubAsset { - name: string; - browser_download_url: string; - size: number; -} - -interface ReleaseData { - version: string; - tag: string; - body: string; - assets: GithubAsset[]; -} - -function pickAsset(assets: GithubAsset[]): GithubAsset | undefined { - if (IS_WINDOWS) { - return assets.find(a => a.name.endsWith('-setup.exe')) - ?? assets.find(a => a.name.endsWith('.exe')); - } - if (IS_MACOS) { - // Prefer Apple Silicon, fall back to Intel - return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64')) - ?? assets.find(a => a.name.endsWith('.dmg')); - } - if (IS_LINUX) { - // AppImage > deb > rpm - return assets.find(a => a.name.endsWith('.AppImage')) - ?? assets.find(a => a.name.endsWith('.deb')) - ?? assets.find(a => a.name.endsWith('.rpm')); - } - return undefined; -} - -type DlState = 'idle' | 'downloading' | 'done' | 'error'; +import { fmtBytes } from '../utils/appUpdaterHelpers'; +import { useAppUpdater } from '../hooks/useAppUpdater'; +import Changelog from './appUpdater/Changelog'; export default function AppUpdater() { const { t } = useTranslation(); - const [release, setRelease] = useState(null); - const [dismissed, setDismissed] = useState(false); - const [changelogOpen, setChangelogOpen] = useState(false); - const [isArch, setIsArch] = useState(false); - const [dlState, setDlState] = useState('idle'); - const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 }); - const [dlPath, setDlPath] = useState(''); - const [dlError, setDlError] = useState(''); - const [countdown, setCountdown] = useState(null); - const unlistenRef = useRef<(() => void) | null>(null); - const countdownRef = useRef(null); - const relaunchFnRef = useRef<(() => Promise) | null>(null); - - const fetchRelease = async (preview = false) => { - try { - const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest'); - if (!res.ok) return; - const data = await res.json(); - const tag: string = data.tag_name ?? ''; - const version = tag.replace(/^[^0-9]*/, ''); - if (!version) return; - if (!preview) { - if (!isNewer(version, currentVersion)) return; - const skipped = localStorage.getItem(SKIP_KEY); - if (skipped === version) return; - } - setDismissed(false); - setDlState('idle'); - setRelease({ - version, - tag, - body: (data.body ?? '').trim(), - assets: data.assets ?? [], - }); - if (IS_LINUX) { - const arch = await invoke('check_arch_linux'); - setIsArch(arch); - } - } catch { - // No network or rate-limited — stay idle - } - }; - - useEffect(() => { - let cancelled = false; - const timer = setTimeout(() => { if (!cancelled) fetchRelease(); }, 4000); - - const handler = () => fetchRelease(true); - window.addEventListener('psysonic:preview-update', handler); - - return () => { - cancelled = true; - clearTimeout(timer); - window.removeEventListener('psysonic:preview-update', handler); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Clean up download listener when component unmounts - useEffect(() => { - return () => { - unlistenRef.current?.(); - if (countdownRef.current) window.clearInterval(countdownRef.current); - }; - }, []); + const { + release, dismissed, setDismissed, changelogOpen, setChangelogOpen, + dlState, dlProgress, dlError, countdown, + asset, showAurHint, useTauriUpdater, showInstallBtn, pct, + handleSkip, handleRestartNow, handleDownload, handleShowFolder, + } = useAppUpdater(); if (!release || dismissed) return null; - const asset = pickAsset(release.assets); - const showAurHint = IS_LINUX && isArch; - // On macOS the Tauri Updater handles architecture, signature verification - // and in-place install — we don't need (and should not show) a DMG asset. - const useTauriUpdater = IS_MACOS; - const showInstallBtn = !showAurHint && (useTauriUpdater || !!asset); - - const handleSkip = () => { - localStorage.setItem(SKIP_KEY, release.version); - setDismissed(true); - }; - - const startRestartCountdown = (seconds: number) => { - let remaining = seconds; - setCountdown(remaining); - countdownRef.current = window.setInterval(() => { - remaining -= 1; - if (remaining <= 0) { - if (countdownRef.current) window.clearInterval(countdownRef.current); - countdownRef.current = null; - setCountdown(null); - relaunchFnRef.current?.(); - } else { - setCountdown(remaining); - } - }, 1000); - }; - - const handleRestartNow = async () => { - if (countdownRef.current) { - window.clearInterval(countdownRef.current); - countdownRef.current = null; - } - setCountdown(null); - await relaunchFnRef.current?.(); - }; - - const handleDownload = async () => { - // On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies - // the minisign signature against the bundled pubkey, replaces the .app, and - // relaunches. No manual "open the DMG" step needed. - if (IS_MACOS) { - setDlState('downloading'); - setDlProgress({ bytes: 0, total: 0 }); - setDlError(''); - try { - const { check } = await import('@tauri-apps/plugin-updater'); - const { relaunch } = await import('@tauri-apps/plugin-process'); - relaunchFnRef.current = relaunch; - const update = await check(); - if (!update) { - setDlError(t('common.updaterErrorMsg')); - setDlState('error'); - return; - } - let downloaded = 0; - let total = 0; - await update.downloadAndInstall(event => { - if (event.event === 'Started') { - total = event.data.contentLength ?? 0; - setDlProgress({ bytes: 0, total }); - } else if (event.event === 'Progress') { - downloaded += event.data.chunkLength; - setDlProgress({ bytes: downloaded, total }); - } else if (event.event === 'Finished') { - setDlState('done'); - // downloadAndInstall replaces the .app in place but does not exit - // the running process. Give the user a 3s countdown (with a manual - // "Restart now" button) before auto-relaunch. - startRestartCountdown(3); - } - }); - } catch (e) { - setDlError(String(e)); - setDlState('error'); - } - return; - } - - if (!asset) return; - setDlState('downloading'); - setDlProgress({ bytes: 0, total: asset.size }); - setDlError(''); - - const unlisten = await listen<{ bytes: number; total: number | null }>( - 'update:download:progress', - e => { - setDlProgress({ - bytes: e.payload.bytes, - total: e.payload.total ?? asset.size, - }); - } - ); - unlistenRef.current = unlisten; - - try { - const finalPath = await invoke('download_update', { - url: asset.browser_download_url, - filename: asset.name, - }); - unlisten(); - unlistenRef.current = null; - setDlPath(finalPath); - setDlState('done'); - } catch (e) { - unlisten(); - unlistenRef.current = null; - setDlError(String(e)); - setDlState('error'); - } - }; - - const handleShowFolder = async () => { - // tauri-plugin-shell's open() only allows https:// per capability scope — - // local paths are blocked and fail silently. Delegate to Rust instead. - const dir = await dirname(dlPath); - await invoke('open_folder', { path: dir }); - }; - - const pct = dlProgress.total > 0 - ? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100)) - : 0; - return createPortal( <>
setDismissed(true)} style={{ zIndex: 3000 }} /> @@ -335,7 +67,7 @@ export default function AppUpdater() { {changelogOpen && (
- {renderChangelog(release.body)} +
)}
diff --git a/src/components/appUpdater/Changelog.tsx b/src/components/appUpdater/Changelog.tsx new file mode 100644 index 00000000..5de4708f --- /dev/null +++ b/src/components/appUpdater/Changelog.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +// Minimal inline-markdown renderer (bold, italic, code) +// IMPORTANT: regex must have NO nested capture groups — split() includes captured +// groups in the result, and nested groups produce undefined entries that crash on .startsWith() +function renderInline(text: string): React.ReactNode[] { + const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); + return parts.map((part, i) => { + if (!part) return null; + if (part.startsWith('**') && part.endsWith('**')) + return {part.slice(2, -2)}; + if (part.startsWith('*') && part.endsWith('*')) + return {part.slice(1, -1)}; + if (part.startsWith('`') && part.endsWith('`')) + return {part.slice(1, -1)}; + return part; + }); +} + +/** Renders a GitHub release body as themed changelog markup. */ +export default function Changelog({ body }: { body: string }) { + return ( + <> + {body.split('\n').map((line, i) => { + if (line.startsWith('### ')) + return
{renderInline(line.slice(4))}
; + if (line.startsWith('#### ')) + return
{renderInline(line.slice(5))}
; + if (line.startsWith('## ')) + return null; // skip nested release headers in body + if (line.startsWith('- ')) + return
{renderInline(line.slice(2))}
; + if (line.trim() === '') return null; + return
{renderInline(line)}
; + })} + + ); +} diff --git a/src/hooks/useAppUpdater.ts b/src/hooks/useAppUpdater.ts new file mode 100644 index 00000000..46fd4f6a --- /dev/null +++ b/src/hooks/useAppUpdater.ts @@ -0,0 +1,211 @@ +import { useEffect, useState, useRef } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { dirname } from '@tauri-apps/api/path'; +import { invoke } from '@tauri-apps/api/core'; +import { useTranslation } from 'react-i18next'; +import { version as currentVersion } from '../../package.json'; +import { IS_LINUX, IS_MACOS } from '../utils/platform'; +import { SKIP_KEY, isNewer, pickAsset, type ReleaseData, type DlState } from '../utils/appUpdaterHelpers'; + +/** All update-modal state, the GitHub release probe and the download/relaunch + * handlers. The component owns only the early-return guard and the JSX. */ +export function useAppUpdater() { + const { t } = useTranslation(); + const [release, setRelease] = useState(null); + const [dismissed, setDismissed] = useState(false); + const [changelogOpen, setChangelogOpen] = useState(false); + const [isArch, setIsArch] = useState(false); + const [dlState, setDlState] = useState('idle'); + const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 }); + const [dlPath, setDlPath] = useState(''); + const [dlError, setDlError] = useState(''); + const [countdown, setCountdown] = useState(null); + const unlistenRef = useRef<(() => void) | null>(null); + const countdownRef = useRef(null); + const relaunchFnRef = useRef<(() => Promise) | null>(null); + + const fetchRelease = async (preview = false) => { + try { + const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest'); + if (!res.ok) return; + const data = await res.json(); + const tag: string = data.tag_name ?? ''; + const version = tag.replace(/^[^0-9]*/, ''); + if (!version) return; + if (!preview) { + if (!isNewer(version, currentVersion)) return; + const skipped = localStorage.getItem(SKIP_KEY); + if (skipped === version) return; + } + setDismissed(false); + setDlState('idle'); + setRelease({ + version, + tag, + body: (data.body ?? '').trim(), + assets: data.assets ?? [], + }); + if (IS_LINUX) { + const arch = await invoke('check_arch_linux'); + setIsArch(arch); + } + } catch { + // No network or rate-limited — stay idle + } + }; + + useEffect(() => { + let cancelled = false; + const timer = setTimeout(() => { if (!cancelled) fetchRelease(); }, 4000); + + const handler = () => fetchRelease(true); + window.addEventListener('psysonic:preview-update', handler); + + return () => { + cancelled = true; + clearTimeout(timer); + window.removeEventListener('psysonic:preview-update', handler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Clean up download listener when component unmounts + useEffect(() => { + return () => { + unlistenRef.current?.(); + if (countdownRef.current) window.clearInterval(countdownRef.current); + }; + }, []); + + const handleSkip = () => { + if (!release) return; + localStorage.setItem(SKIP_KEY, release.version); + setDismissed(true); + }; + + const startRestartCountdown = (seconds: number) => { + let remaining = seconds; + setCountdown(remaining); + countdownRef.current = window.setInterval(() => { + remaining -= 1; + if (remaining <= 0) { + if (countdownRef.current) window.clearInterval(countdownRef.current); + countdownRef.current = null; + setCountdown(null); + relaunchFnRef.current?.(); + } else { + setCountdown(remaining); + } + }, 1000); + }; + + const handleRestartNow = async () => { + if (countdownRef.current) { + window.clearInterval(countdownRef.current); + countdownRef.current = null; + } + setCountdown(null); + await relaunchFnRef.current?.(); + }; + + const asset = pickAsset(release?.assets ?? []); + + const handleDownload = async () => { + // On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies + // the minisign signature against the bundled pubkey, replaces the .app, and + // relaunches. No manual "open the DMG" step needed. + if (IS_MACOS) { + setDlState('downloading'); + setDlProgress({ bytes: 0, total: 0 }); + setDlError(''); + try { + const { check } = await import('@tauri-apps/plugin-updater'); + const { relaunch } = await import('@tauri-apps/plugin-process'); + relaunchFnRef.current = relaunch; + const update = await check(); + if (!update) { + setDlError(t('common.updaterErrorMsg')); + setDlState('error'); + return; + } + let downloaded = 0; + let total = 0; + await update.downloadAndInstall(event => { + if (event.event === 'Started') { + total = event.data.contentLength ?? 0; + setDlProgress({ bytes: 0, total }); + } else if (event.event === 'Progress') { + downloaded += event.data.chunkLength; + setDlProgress({ bytes: downloaded, total }); + } else if (event.event === 'Finished') { + setDlState('done'); + // downloadAndInstall replaces the .app in place but does not exit + // the running process. Give the user a 3s countdown (with a manual + // "Restart now" button) before auto-relaunch. + startRestartCountdown(3); + } + }); + } catch (e) { + setDlError(String(e)); + setDlState('error'); + } + return; + } + + if (!asset) return; + setDlState('downloading'); + setDlProgress({ bytes: 0, total: asset.size }); + setDlError(''); + + const unlisten = await listen<{ bytes: number; total: number | null }>( + 'update:download:progress', + e => { + setDlProgress({ + bytes: e.payload.bytes, + total: e.payload.total ?? asset.size, + }); + } + ); + unlistenRef.current = unlisten; + + try { + const finalPath = await invoke('download_update', { + url: asset.browser_download_url, + filename: asset.name, + }); + unlisten(); + unlistenRef.current = null; + setDlPath(finalPath); + setDlState('done'); + } catch (e) { + unlisten(); + unlistenRef.current = null; + setDlError(String(e)); + setDlState('error'); + } + }; + + const handleShowFolder = async () => { + // tauri-plugin-shell's open() only allows https:// per capability scope — + // local paths are blocked and fail silently. Delegate to Rust instead. + const dir = await dirname(dlPath); + await invoke('open_folder', { path: dir }); + }; + + const showAurHint = IS_LINUX && isArch; + // On macOS the Tauri Updater handles architecture, signature verification + // and in-place install — we don't need (and should not show) a DMG asset. + const useTauriUpdater = IS_MACOS; + const showInstallBtn = !showAurHint && (useTauriUpdater || !!asset); + + const pct = dlProgress.total > 0 + ? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100)) + : 0; + + return { + release, dismissed, setDismissed, changelogOpen, setChangelogOpen, + dlState, dlProgress, dlError, countdown, + asset, showAurHint, useTauriUpdater, showInstallBtn, pct, + handleSkip, handleRestartNow, handleDownload, handleShowFolder, + }; +} diff --git a/src/utils/appUpdaterHelpers.ts b/src/utils/appUpdaterHelpers.ts new file mode 100644 index 00000000..356393df --- /dev/null +++ b/src/utils/appUpdaterHelpers.ts @@ -0,0 +1,53 @@ +import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './platform'; + +export const SKIP_KEY = 'psysonic_skipped_update_version'; + +// Semver comparison: returns true if `a` is newer than `b` +export function isNewer(a: string, b: string): boolean { + const pa = a.replace(/^[^0-9]*/, '').split('.').map(Number); + const pb = b.replace(/^[^0-9]*/, '').split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true; + if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false; + } + return false; +} + +export function fmtBytes(n: number): string { + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +export interface GithubAsset { + name: string; + browser_download_url: string; + size: number; +} + +export interface ReleaseData { + version: string; + tag: string; + body: string; + assets: GithubAsset[]; +} + +export type DlState = 'idle' | 'downloading' | 'done' | 'error'; + +export function pickAsset(assets: GithubAsset[]): GithubAsset | undefined { + if (IS_WINDOWS) { + return assets.find(a => a.name.endsWith('-setup.exe')) + ?? assets.find(a => a.name.endsWith('.exe')); + } + if (IS_MACOS) { + // Prefer Apple Silicon, fall back to Intel + return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64')) + ?? assets.find(a => a.name.endsWith('.dmg')); + } + if (IS_LINUX) { + // AppImage > deb > rpm + return assets.find(a => a.name.endsWith('.AppImage')) + ?? assets.find(a => a.name.endsWith('.deb')) + ?? assets.find(a => a.name.endsWith('.rpm')); + } + return undefined; +}