mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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 { 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>
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user