mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(updater): polish macOS update modal with countdown, trust badges, clean button states
- Footer buttons are now state-dependent: Skip / Remind later only show during idle; the install button no longer leaves a gap when it disappears, and "Remind later" stops shifting between states - Post-install on macOS shows a 3-second visible restart countdown with a "Restart now" button instead of triggering an invisible relaunch that sometimes didn't actually fire - macOS idle state now explains the in-place update model (no DMG download) and shows trust badges: "Notarized by Apple" + "Signature verified" as an at-a-glance trust signal - Download state during install hides all secondary buttons so the progress bar stands alone - New i18n keys in en/de; fr/nl/zh/nb/ru/es fall back to the English defaultValue until translated in a follow-up Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+109
-26
@@ -4,7 +4,7 @@ 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, ChevronDown, Download, FolderOpen, X } from 'lucide-react';
|
||||
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';
|
||||
@@ -103,7 +103,10 @@ export default function AppUpdater() {
|
||||
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
||||
const [dlPath, setDlPath] = useState('');
|
||||
const [dlError, setDlError] = useState('');
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
const countdownRef = useRef<number | null>(null);
|
||||
const relaunchFnRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const fetchRelease = async (preview = false) => {
|
||||
try {
|
||||
@@ -152,7 +155,10 @@ export default function AppUpdater() {
|
||||
|
||||
// Clean up download listener when component unmounts
|
||||
useEffect(() => {
|
||||
return () => { unlistenRef.current?.(); };
|
||||
return () => {
|
||||
unlistenRef.current?.();
|
||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!release || dismissed) return null;
|
||||
@@ -169,6 +175,31 @@ export default function AppUpdater() {
|
||||
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
|
||||
@@ -179,6 +210,8 @@ export default function AppUpdater() {
|
||||
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'));
|
||||
@@ -196,9 +229,12 @@ export default function AppUpdater() {
|
||||
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);
|
||||
}
|
||||
});
|
||||
// downloadAndInstall replaces the .app and relaunches automatically on macOS.
|
||||
} catch (e) {
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
@@ -316,12 +352,25 @@ export default function AppUpdater() {
|
||||
) : useTauriUpdater ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-asset">
|
||||
<span className="update-modal-asset-name">
|
||||
<div className="update-modal-mac-info">
|
||||
<div className="update-modal-mac-info-main">
|
||||
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
|
||||
</div>
|
||||
<div className="update-modal-mac-info-sub">
|
||||
{t('common.updaterMacReady', {
|
||||
defaultValue: 'Downloads, verifies and installs automatically. The app will restart when done.',
|
||||
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="update-modal-trust-badges">
|
||||
<span className="update-modal-trust-badge">
|
||||
<ShieldCheck size={12} />
|
||||
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
|
||||
</span>
|
||||
<span className="update-modal-trust-badge">
|
||||
<CheckCircle2 size={12} />
|
||||
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
@@ -338,8 +387,14 @@ export default function AppUpdater() {
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<CheckCircle2 size={32} className="update-modal-done-icon" />
|
||||
<div className="update-modal-done-title">
|
||||
{t('common.updaterMacDone', { defaultValue: 'Installed. Restarting…' })}
|
||||
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
|
||||
</div>
|
||||
<div className="update-modal-done-countdown">
|
||||
{countdown !== null
|
||||
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
|
||||
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -394,27 +449,55 @@ export default function AppUpdater() {
|
||||
</div>
|
||||
</div>{/* end update-modal-body */}
|
||||
|
||||
{/* Footer buttons */}
|
||||
{/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
|
||||
<div className="update-modal-footer">
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{showInstallBtn && dlState === 'idle' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{useTauriUpdater
|
||||
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
|
||||
: t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
{dlState === 'idle' && (
|
||||
<>
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{showInstallBtn && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{useTauriUpdater
|
||||
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
|
||||
: t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
|
||||
{dlState === 'done' && useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary" onClick={handleRestartNow}>
|
||||
<RefreshCw size={14} />
|
||||
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'done' && !useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -407,6 +407,15 @@ export const deTranslation = {
|
||||
updaterInstallHint: 'Psysonic schließen und das Installationsprogramm manuell ausführen.',
|
||||
updaterAurHint: 'Update über AUR installieren:',
|
||||
updaterErrorMsg: 'Download fehlgeschlagen',
|
||||
updaterInstallNow: 'Jetzt installieren',
|
||||
updaterMacReadyTitle: 'Bereit zur Installation',
|
||||
updaterMacReady: 'Das Update wird heruntergeladen, verifiziert und direkt übernommen — keine DMG nötig. Die App startet anschließend automatisch neu.',
|
||||
updaterTrustNotarized: 'Von Apple beglaubigt',
|
||||
updaterTrustSignature: 'Signatur verifiziert',
|
||||
updaterMacDoneTitle: 'Update installiert',
|
||||
updaterRestartingIn: 'Neustart in {{n}}s …',
|
||||
updaterRestarting: 'Neustart läuft …',
|
||||
updaterRestartNow: 'Jetzt neu starten',
|
||||
updaterRetryBtn: 'Erneut versuchen',
|
||||
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
|
||||
durationMinutesOnly: '{{minutes}} Min.',
|
||||
|
||||
@@ -409,6 +409,15 @@ export const enTranslation = {
|
||||
updaterAurHint: 'Install the update via AUR:',
|
||||
updaterErrorMsg: 'Download failed',
|
||||
updaterRetryBtn: 'Retry',
|
||||
updaterInstallNow: 'Install now',
|
||||
updaterMacReadyTitle: 'Ready to install',
|
||||
updaterMacReady: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
|
||||
updaterTrustNotarized: 'Notarized by Apple',
|
||||
updaterTrustSignature: 'Signature verified',
|
||||
updaterMacDoneTitle: 'Update installed',
|
||||
updaterRestartingIn: 'Restarting in {{n}}s…',
|
||||
updaterRestarting: 'Restarting…',
|
||||
updaterRestartNow: 'Restart now',
|
||||
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||
durationMinutesOnly: '{{minutes}}m',
|
||||
updaterOpenGitHub: 'Open on GitHub',
|
||||
|
||||
@@ -769,6 +769,53 @@
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* macOS Tauri Updater — idle state info block */
|
||||
.update-modal-mac-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.update-modal-mac-info-main {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.update-modal-mac-info-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.update-modal-trust-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.update-modal-trust-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--positive, var(--accent));
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.update-modal-trust-badge svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* macOS Tauri Updater — done state (after install, before/during restart) */
|
||||
.update-modal-done-icon {
|
||||
align-self: center;
|
||||
color: var(--positive, var(--accent));
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.update-modal-done-countdown {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* AUR hint */
|
||||
.update-modal-aur {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user