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:
Psychotoxical
2026-04-18 17:44:50 +02:00
parent 36f0ef79fe
commit e201bc630c
4 changed files with 174 additions and 26 deletions
+109 -26
View File
@@ -4,7 +4,7 @@ import { open } from '@tauri-apps/plugin-shell';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { dirname } from '@tauri-apps/api/path'; import { dirname } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core'; 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 { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../package.json'; import { version as currentVersion } from '../../package.json';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; 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 [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
const [dlPath, setDlPath] = useState(''); const [dlPath, setDlPath] = useState('');
const [dlError, setDlError] = useState(''); const [dlError, setDlError] = useState('');
const [countdown, setCountdown] = useState<number | null>(null);
const unlistenRef = useRef<(() => void) | 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) => { const fetchRelease = async (preview = false) => {
try { try {
@@ -152,7 +155,10 @@ export default function AppUpdater() {
// Clean up download listener when component unmounts // Clean up download listener when component unmounts
useEffect(() => { useEffect(() => {
return () => { unlistenRef.current?.(); }; return () => {
unlistenRef.current?.();
if (countdownRef.current) window.clearInterval(countdownRef.current);
};
}, []); }, []);
if (!release || dismissed) return null; if (!release || dismissed) return null;
@@ -169,6 +175,31 @@ export default function AppUpdater() {
setDismissed(true); 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 () => { const handleDownload = async () => {
// On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies // On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies
// the minisign signature against the bundled pubkey, replaces the .app, and // the minisign signature against the bundled pubkey, replaces the .app, and
@@ -179,6 +210,8 @@ export default function AppUpdater() {
setDlError(''); setDlError('');
try { try {
const { check } = await import('@tauri-apps/plugin-updater'); const { check } = await import('@tauri-apps/plugin-updater');
const { relaunch } = await import('@tauri-apps/plugin-process');
relaunchFnRef.current = relaunch;
const update = await check(); const update = await check();
if (!update) { if (!update) {
setDlError(t('common.updaterErrorMsg')); setDlError(t('common.updaterErrorMsg'));
@@ -196,9 +229,12 @@ export default function AppUpdater() {
setDlProgress({ bytes: downloaded, total }); setDlProgress({ bytes: downloaded, total });
} else if (event.event === 'Finished') { } else if (event.event === 'Finished') {
setDlState('done'); 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) { } catch (e) {
setDlError(String(e)); setDlError(String(e));
setDlState('error'); setDlState('error');
@@ -316,12 +352,25 @@ export default function AppUpdater() {
) : useTauriUpdater ? ( ) : useTauriUpdater ? (
<> <>
{dlState === 'idle' && ( {dlState === 'idle' && (
<div className="update-modal-asset"> <div className="update-modal-mac-info">
<span className="update-modal-asset-name"> <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', { {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> </div>
)} )}
{dlState === 'downloading' && ( {dlState === 'downloading' && (
@@ -338,8 +387,14 @@ export default function AppUpdater() {
)} )}
{dlState === 'done' && ( {dlState === 'done' && (
<div className="update-modal-done"> <div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title"> <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>
</div> </div>
)} )}
@@ -394,27 +449,55 @@ export default function AppUpdater() {
</div> </div>
</div>{/* end update-modal-body */} </div>{/* end update-modal-body */}
{/* Footer buttons */} {/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
<div className="update-modal-footer"> <div className="update-modal-footer">
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}> {dlState === 'idle' && (
{t('common.updaterSkipBtn')} <>
</button> <button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
<div style={{ flex: 1 }} /> {t('common.updaterSkipBtn')}
<button className="btn btn-surface" onClick={() => setDismissed(true)}> </button>
{t('common.updaterRemindBtn')} <div style={{ flex: 1 }} />
</button> <button className="btn btn-surface" onClick={() => setDismissed(true)}>
{showInstallBtn && dlState === 'idle' && ( {t('common.updaterRemindBtn')}
<button className="btn btn-primary" onClick={handleDownload}> </button>
<Download size={14} /> {showInstallBtn && (
{useTauriUpdater <button className="btn btn-primary" onClick={handleDownload}>
? t('common.updaterInstallNow', { defaultValue: 'Install now' }) <Download size={14} />
: t('common.updaterDownloadBtn')} {useTauriUpdater
</button> ? 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' && ( {dlState === 'error' && (
<button className="btn btn-primary" onClick={handleDownload}> <>
{t('common.updaterRetryBtn')} <div style={{ flex: 1 }} />
</button> <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>
</div> </div>
+9
View File
@@ -407,6 +407,15 @@ export const deTranslation = {
updaterInstallHint: 'Psysonic schließen und das Installationsprogramm manuell ausführen.', updaterInstallHint: 'Psysonic schließen und das Installationsprogramm manuell ausführen.',
updaterAurHint: 'Update über AUR installieren:', updaterAurHint: 'Update über AUR installieren:',
updaterErrorMsg: 'Download fehlgeschlagen', 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', updaterRetryBtn: 'Erneut versuchen',
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.', durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
durationMinutesOnly: '{{minutes}} Min.', durationMinutesOnly: '{{minutes}} Min.',
+9
View File
@@ -409,6 +409,15 @@ export const enTranslation = {
updaterAurHint: 'Install the update via AUR:', updaterAurHint: 'Install the update via AUR:',
updaterErrorMsg: 'Download failed', updaterErrorMsg: 'Download failed',
updaterRetryBtn: 'Retry', 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', durationHoursMinutes: '{{hours}}h {{minutes}}m',
durationMinutesOnly: '{{minutes}}m', durationMinutesOnly: '{{minutes}}m',
updaterOpenGitHub: 'Open on GitHub', updaterOpenGitHub: 'Open on GitHub',
+47
View File
@@ -769,6 +769,53 @@
gap: var(--space-2); gap: var(--space-2);
font-size: 12px; 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 */ /* AUR hint */
.update-modal-aur { .update-modal-aur {
display: flex; display: flex;