1- import { app } from "electron" ;
1+ import { app , dialog } from "electron" ;
22// electron-updater is CJS; default-import + destructure is the safe ESM interop.
33import electronUpdater from "electron-updater" ;
44
@@ -22,6 +22,13 @@ let state: UpdateState = { status: "idle" };
2222let pendingVersion = "" ;
2323let 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.
2734const 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+
86156function set ( next : UpdateState ) : void {
87157 state = next ;
88158 onChange ?.( ) ;
0 commit comments