diff --git a/src/renderer/src/components/UpdateBadge.tsx b/src/renderer/src/components/UpdateBadge.tsx index cf9d3aa41..3c0cdd6ee 100644 --- a/src/renderer/src/components/UpdateBadge.tsx +++ b/src/renderer/src/components/UpdateBadge.tsx @@ -25,6 +25,11 @@ export function UpdateBadge() { /** The version whose download was just started, for the "now replace the * app" notice. Local state: it is a one-off explanation, not an update state. */ const [started, setStarted] = useState(null); + /** Brief, positive "checked, you are current" flash after a MANUAL check that + * found no update. Without it a successful check settles silently back to the + * grey "latest" chip, which is indistinguishable from a click that did + * nothing, and that is exactly why the badge read as broken. */ + const [checkedOk, setCheckedOk] = useState(false); useEffect(() => { // Subscribe first, then pull — main may have emitted before this window @@ -36,13 +41,34 @@ export function UpdateBadge() { return off; }, []); + // The acknowledgement is a flash, not a mode: clear it after a few seconds so + // the badge returns to its quiet resting state. + useEffect(() => { + if (!checkedOk) return; + const t = setTimeout(() => setCheckedOk(false), 3500); + return () => clearTimeout(t); + }, [checkedOk]); + const view = describeUpdate(status, __APP_VERSION__); const onClick = useCallback(async () => { if (view.action === 'none' || busy) return; setBusy(true); try { - if (view.action === 'check') await window.cth.updateCheckNow(); + if (view.action === 'check') { + const res = await window.cth.updateCheckNow(); + // A successful "already current" check has to say so out loud. runCheck + // has settled lastStatus by the time this resolves, so read it back: a + // no-update result flashes the acknowledgement; an available update is + // already loud on its own (the chip changes) so it is left alone. + if (res?.ok) { + const cur = await window.cth.updateCurrent?.(); + const st = cur?.state; + if (!st || st === 'not-available' || st === 'idle' || st === 'just-updated') setCheckedOk(true); + } + } + else if (view.action === 'download') await window.cth.updateDownload(); + else if (view.action === 'restart') await window.cth.updateRestartAndInstall(); else if (view.action === 'manual' && status) { // The click IS the download. Auto-update lives in Settings. const url = manualDownloadUrl(status, window.cth.platform, window.cth.arch); @@ -107,7 +133,7 @@ export function UpdateBadge() { {/* Hover card: what the click does and what to do with the file, for this OS. */} - {pending && hover && !started && ( + {view.action === 'manual' && hover && !started && (
)} + {/* A successful "you are already current" check must be visible, or it is + indistinguishable from a dead click. Shows only for the manual-check + no-update result, and auto-dismisses. */} + {checkedOk && !started && ( +
+
+ ✓ + You are on the latest version. +
+
+ v{__APP_VERSION__} is the newest release. Checked just now. +
+
+ )} ); } diff --git a/src/shared/updateState.ts b/src/shared/updateState.ts index 6cb5b863c..ad2e7665e 100644 --- a/src/shared/updateState.ts +++ b/src/shared/updateState.ts @@ -158,6 +158,23 @@ export function describeUpdate(status: UpdateStatus | null, currentVersion: stri } const pending = pendingVersion(status, v); if (pending) { + // When the native updater has staged the update, the badge drives the SAME + // auto-update the Settings pane does: restart to install once it is + // downloaded, or kick the download while it is 'available'. Manual download + // is reserved for 'available-manual', the notify-only fallback where the + // native updater could NOT fetch it, so the user replaces the app by hand. + if (status?.state === 'downloaded') { + return { + label: `v${pending} · restart`, action: 'restart', tone: 'ready', busy: false, + title: `Click to restart and install v${pending}` + }; + } + if (status?.state === 'available') { + return { + label: `v${pending} · update`, action: 'download', tone: 'ready', busy: false, + title: `Downloading v${pending} in the background; click to start it if it has not begun` + }; + } const why = status?.state === 'available-manual' && status.reason ? ` (this install could not update itself: ${status.reason})` : ''; return { diff --git a/test/update-badge-acknowledgement.test.cjs b/test/update-badge-acknowledgement.test.cjs new file mode 100644 index 000000000..5c52d405c --- /dev/null +++ b/test/update-badge-acknowledgement.test.cjs @@ -0,0 +1,35 @@ +/** + * A successful update check must be visible. + * + * The complaint that started this: on the latest release, a manual check + * succeeds, finds nothing, and the badge settles back to a quiet grey "latest" + * chip. A check that worked perfectly and a click that never registered look + * identical, so the button reads as broken. The fix is a brief, positive + * acknowledgement after a manual no-update check. Source-string checks, because + * UpdateBadge is a React component with no jsdom harness in this repo; deleting + * the acknowledgement wiring must not pass silently. + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const read = (rel) => readFileSync(join(__dirname, '..', rel), 'utf8'); +const SRC = read('src/renderer/src/components/UpdateBadge.tsx'); + +test('the check branch acknowledges a no-update result', () => { + assert.ok(/setCheckedOk\(true\)/.test(SRC), + 'the manual check must set the acknowledgement, or a successful check stays silent'); + assert.ok(/updateCheckNow\(\)/.test(SRC) && /updateCurrent\?\.\(\)/.test(SRC), + 'it reads the settled status back after the check to tell a no-update result from an available one'); + assert.ok(/'not-available'/.test(SRC), + 'the acknowledgement is gated on a no-update state, not fired blindly for every check'); +}); + +test('the acknowledgement renders and auto-dismisses', () => { + assert.ok(/checkedOk && !started &&/.test(SRC), + 'the acknowledgement popover must render when checkedOk is set'); + assert.ok(/on the latest version/i.test(SRC), + 'it must say, in words, that the user is already current'); + assert.ok(/setTimeout\(\(\) => setCheckedOk\(false\)/.test(SRC), + 'it must auto-dismiss, or it is a stuck mode instead of a flash'); +}); diff --git a/test/update-state.test.cjs b/test/update-state.test.cjs index b75ecc89c..048680ece 100644 --- a/test/update-state.test.cjs +++ b/test/update-state.test.cjs @@ -87,16 +87,16 @@ test('every actionable state offers the action its label promises', () => { check({ state: 'not-available' }, { action: 'check', label: 'latest' }); check({ state: 'checking' }, { action: 'none', busy: true }); - check({ state: 'available', version: '0.3.7' }, { action: 'manual', busy: false }); + check({ state: 'available', version: '0.3.7' }, { action: 'download', busy: false }); check({ state: 'downloading', version: '0.3.7', percent: 42 }, { action: 'none', busy: true }); - check({ state: 'downloaded', version: '0.3.7' }, { action: 'manual', busy: false }); + check({ state: 'downloaded', version: '0.3.7' }, { action: 'restart', busy: false }); check({ state: 'available-manual', version: '0.3.7', url: 'https://x' }, { action: 'manual' }); check({ state: 'error', message: 'boom' }, { action: 'check' }); }); test('labels name the version so the badge is self-explanatory', () => { assert.match(describeUpdate({ state: 'available', version: '0.3.7' }, '0.3.6').label, /0\.3\.7/); - assert.match(describeUpdate({ state: 'downloaded', version: '0.3.7' }, '0.3.6').label, /download/i); + assert.match(describeUpdate({ state: 'downloaded', version: '0.3.7' }, '0.3.6').label, /restart/i); assert.match(describeUpdate({ state: 'downloading', version: '0.3.7', percent: 42.4 }, '0.3.6').label, /42%/); });