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;
+}