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'; 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); }; }, []); 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 }} />
e.stopPropagation()} > {/* Header */}
{t('common.updaterModalTitle')} v{currentVersion} → v{release.version}
{/* Scrollable body: changelog + download area — single overflow container */}
{/* Collapsible Changelog */} {release.body && (
{changelogOpen && (
{renderChangelog(release.body)}
)}
)} {/* Download / AUR area */}
{showAurHint ? (
{t('common.updaterAurHint')}
yay -S psysonic-bin sudo pacman -Syu psysonic-bin
) : useTauriUpdater ? ( <> {dlState === 'idle' && (
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
{t('common.updaterMacReady', { defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.', })}
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })} {t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
)} {dlState === 'downloading' && (
{pct}% {fmtBytes(dlProgress.bytes)} {dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
)} {dlState === 'done' && (
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
{countdown !== null ? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown }) : t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
)} {dlState === 'error' && (
{dlError || t('common.updaterErrorMsg')}
)} ) : asset ? ( <> {dlState === 'idle' && (
{asset.name} {fmtBytes(asset.size)}
)} {dlState === 'downloading' && (
{pct}% {fmtBytes(dlProgress.bytes)} {dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
)} {dlState === 'done' && (
{t('common.updaterDone')}
{t('common.updaterInstallHint')}
)} {dlState === 'error' && (
{dlError || t('common.updaterErrorMsg')}
)} ) : (
)}
{/* end update-modal-body */} {/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
{dlState === 'idle' && ( <>
{showInstallBtn && ( )} )} {dlState === 'downloading' &&
} {dlState === 'done' && useTauriUpdater && ( <>
)} {dlState === 'done' && !useTauriUpdater && ( <>
)} {dlState === 'error' && ( <>
)}
, document.body ); }