Skip to content

Commit 6418126

Browse files
yyq1025claude
andcommitted
menubar: interactive update-check dialogs + tray download progress
Sparkle's convention, adopted: a user-initiated "Check for updates" always answers with visible UI — "You're up to date", a Restart Now / Later prompt once the download lands, or the error — while scheduled background checks stay silent (menu rows only). Download progress goes in the tray TITLE (" 42%" beside the icon): macOS doesn't repaint an open NSMenu, so the menu row alone can't show live progress, and the tray title is the one surface visible without opening anything. LSUIElement app → app.focus({steal:true}) before each dialog so it doesn't open behind the frontmost app. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent eac74d8 commit 6418126

2 files changed

Lines changed: 89 additions & 10 deletions

File tree

packages/menubar/electron/main.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,10 @@ function buildMenu(): Electron.Menu {
194194
{ label: `Version ${app.getVersion()}`, enabled: false },
195195
{
196196
label: "Check for updates",
197-
click: () => checkForUpdates(),
197+
// interactive: a user-initiated check always answers with a
198+
// dialog (up to date / restart prompt / error) — Sparkle
199+
// convention; scheduled checks stay silent.
200+
click: () => checkForUpdates({ interactive: true }),
198201
},
199202
{
200203
label: "View on GitHub",
@@ -217,6 +220,12 @@ function buildMenu(): Electron.Menu {
217220

218221
function refreshMenu() {
219222
tray?.setContextMenu(buildMenu());
223+
// Download progress lives in the tray TITLE (text beside the icon) — the
224+
// one surface a menu bar app can update that's visible without opening
225+
// anything. macOS doesn't repaint an already-open NSMenu, so the menu row
226+
// alone would only show progress on the next open.
227+
const s = getUpdateState();
228+
tray?.setTitle(s.status === "downloading" ? ` ${s.percent}%` : "");
220229
}
221230

222231
// --- Pair window ---

packages/menubar/electron/updater.ts

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { app } from "electron";
1+
import { app, dialog } from "electron";
22
// electron-updater is CJS; default-import + destructure is the safe ESM interop.
33
import electronUpdater from "electron-updater";
44

@@ -22,6 +22,13 @@ let state: UpdateState = { status: "idle" };
2222
let pendingVersion = "";
2323
let onChange: (() => void) | null = null;
2424

25+
// Sparkle's convention, adopted here: a USER-initiated check must always
26+
// answer with visible UI (dialog), while scheduled background checks stay
27+
// silent (menu rows + tray title only). This flag marks the in-flight check
28+
// as user-initiated; each terminal outcome (up to date / downloaded / error)
29+
// consumes it.
30+
let interactiveCheck = false;
31+
2532
// 6h: frequent enough to keep the protocol-mismatch window to hours, rare
2633
// enough to be invisible in network logs.
2734
const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -44,22 +51,62 @@ export function initUpdater(opts: { onStateChange: () => void }): void {
4451
autoUpdater.on("checking-for-update", () => set({ status: "checking" }));
4552
autoUpdater.on("update-available", (info) => {
4653
pendingVersion = info.version;
54+
// No dialog here even for interactive checks: the tray title starts
55+
// showing live "⬇ N%" immediately (right where the user just clicked),
56+
// and the flag survives until update-downloaded prompts the restart.
4757
set({ status: "downloading", version: info.version, percent: 0 });
4858
});
49-
autoUpdater.on("update-not-available", () => set({ status: "idle" }));
59+
autoUpdater.on("update-not-available", () => {
60+
set({ status: "idle" });
61+
if (consumeInteractive()) {
62+
showDialog({
63+
message: "You're up to date",
64+
detail: `Sidecode ${app.getVersion()} is the latest version.`,
65+
});
66+
}
67+
});
5068
autoUpdater.on("download-progress", (p) =>
5169
set({
5270
status: "downloading",
5371
version: pendingVersion,
5472
percent: Math.round(p.percent),
5573
}),
5674
);
57-
autoUpdater.on("update-downloaded", (info) =>
58-
set({ status: "downloaded", version: info.version }),
59-
);
60-
autoUpdater.on("error", (err) =>
61-
set({ status: "error", message: err?.message ?? String(err) }),
62-
);
75+
autoUpdater.on("update-downloaded", (info) => {
76+
set({ status: "downloaded", version: info.version });
77+
// A user who manually checked almost certainly wants to install right
78+
// away — offer the restart instead of making them reopen the menu.
79+
// (electron-updater re-fires this immediately for an already-cached
80+
// download, so a manual re-check while in "downloaded" lands here too.)
81+
if (consumeInteractive()) {
82+
app.focus({ steal: true }); // see showDialog
83+
void dialog
84+
.showMessageBox({
85+
type: "info",
86+
message: `Sidecode ${info.version} is ready`,
87+
detail: "Restart now to finish updating?",
88+
buttons: ["Restart Now", "Later"],
89+
defaultId: 0,
90+
cancelId: 1,
91+
})
92+
.then(({ response }) => {
93+
if (response === 0) quitAndInstall();
94+
});
95+
}
96+
});
97+
autoUpdater.on("error", (err) => {
98+
const message = err?.message ?? String(err);
99+
set({ status: "error", message });
100+
// Background-check failures (laptop offline, feed hiccup) stay silent —
101+
// the 6h cadence retries on its own.
102+
if (consumeInteractive()) {
103+
showDialog({
104+
type: "warning",
105+
message: "Couldn't check for updates",
106+
detail: message,
107+
});
108+
}
109+
});
63110

64111
checkForUpdates();
65112
// Menu bar apps run for weeks; a startup-only check would let the Mac
@@ -69,7 +116,8 @@ export function initUpdater(opts: { onStateChange: () => void }): void {
69116
setInterval(checkForUpdates, RECHECK_INTERVAL_MS).unref();
70117
}
71118

72-
export function checkForUpdates(): void {
119+
export function checkForUpdates(opts: { interactive?: boolean } = {}): void {
120+
if (opts.interactive) interactiveCheck = true;
73121
// Failures also fire the "error" event; this catch just prevents an unhandled
74122
// rejection when no feed is reachable (e.g. dev without a real dev-app-update.yml).
75123
autoUpdater.checkForUpdates().catch((err) => {
@@ -83,6 +131,28 @@ export function quitAndInstall(): void {
83131
autoUpdater.quitAndInstall();
84132
}
85133

134+
function consumeInteractive(): boolean {
135+
const was = interactiveCheck;
136+
interactiveCheck = false;
137+
return was;
138+
}
139+
140+
function showDialog(opts: {
141+
type?: "info" | "warning";
142+
message: string;
143+
detail: string;
144+
}): void {
145+
// LSUIElement app (no Dock presence): without an explicit focus steal the
146+
// dialog can open behind whatever app is frontmost.
147+
app.focus({ steal: true });
148+
void dialog.showMessageBox({
149+
type: opts.type ?? "info",
150+
message: opts.message,
151+
detail: opts.detail,
152+
buttons: ["OK"],
153+
});
154+
}
155+
86156
function set(next: UpdateState): void {
87157
state = next;
88158
onChange?.();

0 commit comments

Comments
 (0)