Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions src/renderer/src/components/UpdateBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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
Expand All @@ -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);
Expand Down Expand Up @@ -107,7 +133,7 @@ export function UpdateBadge() {
</button>

{/* Hover card: what the click does and what to do with the file, for this OS. */}
{pending && hover && !started && (
{view.action === 'manual' && hover && !started && (
<div
role="tooltip"
className="cth-titlebar-nodrag"
Expand Down Expand Up @@ -164,6 +190,35 @@ export function UpdateBadge() {
</div>
</div>
)}
{/* 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 && (
<div
role="status"
aria-live="polite"
className="cth-titlebar-nodrag"
style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, zIndex: 400,
width: 300, padding: '10px 12px',
background: 'var(--cth-paper-100)', color: INK,
border: `2px solid ${INK}`, boxShadow: `4px 4px 0 ${INK}`,
fontFamily: 'var(--cth-font-ui)', fontSize: 12.5, lineHeight: 1.5, textAlign: 'left'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontFamily: 'var(--cth-font-mono, monospace)', fontWeight: 700, fontSize: 13 }}>
<span aria-hidden style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 18, height: 18, borderRadius: 999,
background: 'var(--cth-mint-light, #d0f0e0)', color: 'var(--cth-ink-900)', fontSize: 12
}}>&#10003;</span>
You are on the latest version.
</div>
<div style={{ marginTop: 4, color: 'var(--cth-ink-700)' }}>
v{__APP_VERSION__} is the newest release. Checked just now.
</div>
</div>
)}
</span>
);
}
17 changes: 17 additions & 0 deletions src/shared/updateState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions test/update-badge-acknowledgement.test.cjs
Original file line number Diff line number Diff line change
@@ -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');
});
6 changes: 3 additions & 3 deletions test/update-state.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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%/);
});

Expand Down
Loading