From 8cae227c77869a0f19d7f973dfa96b14bac99903 Mon Sep 17 00:00:00 2001 From: ltx-desktop-bot Date: Wed, 19 Aug 2026 13:01:13 +0000 Subject: [PATCH] Sync from internal - 2026-08-19 --- electron/updater.ts | 105 +++++++++++++++++++++++--- frontend/components/SettingsModal.tsx | 65 +++++++++++++--- frontend/hooks/use-app-update.ts | 7 +- package.json | 2 +- 4 files changed, 157 insertions(+), 22 deletions(-) diff --git a/electron/updater.ts b/electron/updater.ts index e0c58a8c7..da3424f73 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -18,6 +18,9 @@ const MAX_RELEASE_NOTES_CHARS = 16_384 // The single in-memory value of update state. Broadcast on every change. let state: UpdateStatePayload = { status: 'idle', currentVersion: app.getVersion() } let periodicHandle: ReturnType | null = null +// Mac toggle-off sets UI to idle without aborting HTTP. Track the transfer so a +// new check cannot start while a zip is still downloading. +let macInFlight = false function setState(patch: Partial): void { state = { ...state, ...patch } @@ -34,9 +37,20 @@ export function getUpdateState(): UpdateStatePayload { return state } +function beginMacFlight(): void { + if (process.platform === 'darwin') macInFlight = true +} + +function endMacFlight(): void { + macInFlight = false +} + // True when we must not start or clobber with a new check. function isBusy(): boolean { - return state.status === 'checking' || state.status === 'downloading' || state.status === 'downloaded' + return macInFlight + || state.status === 'checking' + || state.status === 'downloading' + || state.status === 'downloaded' } function hasRestorableOffer(): boolean { @@ -46,6 +60,16 @@ function hasRestorableOffer(): boolean { // Network/feed errors must not drop a known offer or a finished download. function failUpdate(message: string): void { logger.error(`[updater] ${message}`) + if (process.platform === 'darwin') { + // No modal / Try again. Keep a finished download; otherwise idle so Check retries. + if (state.status === 'downloaded') { + setState({ message }) + return + } + endMacFlight() + setState({ status: 'idle', message }) + return + } if (state.status === 'downloading') { setState({ status: 'available', message }) return @@ -63,6 +87,7 @@ function failUpdate(message: string): void { function runCheck(): void { if (isBusy()) return // never interrupt an in-flight download or a downloaded-and-waiting state + beginMacFlight() logger.info('[updater] Checking for update...') autoUpdater.checkForUpdates().catch((e) => { failUpdate(e instanceof Error ? e.message : String(e)) @@ -77,21 +102,54 @@ export function armPeriodicCheck(): void { } } +// Mac has no update modal: the About toggle is the whole decision. +// On = check, auto-download, feed Squirrel so quit installs (1.2.0-style). +// Off = none of that. Windows/Linux keep user-gated download/install. +// Flipping off cannot abort an in-flight HTTP download (no CancellationToken). +// Squirrel is only fed when the zip finishes with autoInstallOnAppQuit still true; +// after that, toggling off does not un-stage the update. +function syncMacSilentUpdates(): void { + const enabled = process.platform === 'darwin' && getAutoCheckUpdates() + autoUpdater.autoDownload = enabled + autoUpdater.autoInstallOnAppQuit = enabled +} + export function initAutoUpdater(channel: UpdateChannel = 'latest'): void { if (channel !== 'latest') { autoUpdater.channel = channel autoUpdater.allowPrerelease = true } - // Core change: the user controls download and install. + // Windows/Linux: user controls download and install. Mac: syncMacSilentUpdates. autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = false + syncMacSilentUpdates() - autoUpdater.on('checking-for-update', () => setState({ status: 'checking', message: undefined })) + autoUpdater.on('checking-for-update', () => { + if (process.platform === 'darwin' && !getAutoCheckUpdates()) return + setState({ status: 'checking', message: undefined }) + }) autoUpdater.on('update-available', (info: UpdateInfo) => { const version = info.version - // Skip enforcement: if the user skipped THIS version, stay silent (idle). + if (process.platform === 'darwin') { + // Skip does not apply. If the toggle is off, do not sit in Windows `available` + // (that would disable Check and claim a download). autoDownload starts next. + if (!getAutoCheckUpdates()) { + endMacFlight() + setState({ status: 'idle', version }) + return + } + setState({ + status: 'downloading', + version, + percent: 0, + releaseNotes: releaseNotesFromFeed(info), + message: undefined, + }) + return + } + // Skip is a Windows/Linux modal action. if (getSkippedUpdateVersion() === version) { setState({ status: 'idle', version }) return @@ -105,15 +163,28 @@ export function initAutoUpdater(channel: UpdateChannel = 'latest'): void { }) }) - autoUpdater.on('update-not-available', () => setState({ status: 'not-available', message: undefined })) + autoUpdater.on('update-not-available', () => { + endMacFlight() + setState({ status: 'not-available', message: undefined }) + }) - autoUpdater.on('download-progress', (p: ProgressInfo) => - setState({ status: 'downloading', percent: Math.round(p.percent) }), - ) + autoUpdater.on('download-progress', (p: ProgressInfo) => { + if (process.platform === 'darwin' && !getAutoCheckUpdates()) return + setState({ status: 'downloading', percent: Math.round(p.percent) }) + }) autoUpdater.on('update-downloaded', async (info: UpdateInfo) => { + endMacFlight() + if (process.platform === 'darwin') { + // Squirrel is fed only when autoInstallOnAppQuit is still true at zip-complete. + if (!getAutoCheckUpdates()) { + setState({ status: 'idle', version: info.version }) + return + } + setState({ status: 'downloaded', version: info.version }) + return // macOS: no python pre-download (unchanged) + } setState({ status: 'downloaded', version: info.version }) - if (process.platform === 'darwin') return // macOS: no python pre-download (unchanged) logger.info(`[updater] Update downloaded: v${info.version}, pre-downloading python deps...`) try { @@ -139,11 +210,13 @@ export function initAutoUpdater(channel: UpdateChannel = 'latest'): void { // ---- Actions called from IPC handlers ---- -// Manual "Check for updates": explicit user intent. Clear any skip and force a check even if -// auto-check is off. +// Manual "Check for updates". Windows: allowed even if auto-check is off (modal still +// gates download). Mac: the toggle is the opt-out, so a check with it off is a no-op. export async function checkForUpdatesNow(): Promise { if (isBusy()) return + if (process.platform === 'darwin' && !getAutoCheckUpdates()) return setSkippedUpdateVersion(undefined) // an explicit check overrides a previous skip + beginMacFlight() logger.info('[updater] Manual check for updates...') try { await autoUpdater.checkForUpdates() @@ -186,4 +259,14 @@ export function skipUpdateVersion(version: string): void { export function setAutoCheckUpdatesEnabled(enabled: boolean): void { setAutoCheckUpdates(enabled) armPeriodicCheck() + syncMacSilentUpdates() + if (process.platform !== 'darwin') return + if (enabled) { + runCheck() // no-op while macInFlight (HTTP still running after toggle-off) + return + } + // Drop in-flight check/download UI. Leave `downloaded` — Squirrel is already staged. + if (state.status === 'checking' || state.status === 'available' || state.status === 'downloading') { + setState({ status: 'idle' }) + } } diff --git a/frontend/components/SettingsModal.tsx b/frontend/components/SettingsModal.tsx index d40377f48..b4834e430 100644 --- a/frontend/components/SettingsModal.tsx +++ b/frontend/components/SettingsModal.tsx @@ -149,7 +149,29 @@ function aboutUpdateAction( state: UpdateStatePayload, onOpenUpdate: () => void, onCheckForUpdates: () => void, + isMac: boolean, + autoCheckOn: boolean, ): { label: string; onClick?: () => void; disabled?: boolean } { + if (isMac) { + // No modal: Check only forces a lookup. Download/install still follow the toggle. + switch (state.status) { + case 'checking': + return { label: 'Checking…', disabled: true } + case 'downloading': + return { label: `Downloading… ${state.percent ?? 0}%`, disabled: true } + case 'downloaded': + return { + label: autoCheckOn ? 'Will install when you quit' : 'Will still install when you quit', + disabled: true, + } + default: + return { + label: 'Check for updates', + onClick: autoCheckOn ? onCheckForUpdates : undefined, + disabled: !autoCheckOn, + } + } + } switch (state.status) { case 'available': return { label: `Update available — v${state.version}`, onClick: onOpenUpdate } @@ -217,7 +239,8 @@ export function SettingsModal({ isOpen, onClose, initialTab, update, onOpenUpdat const [analyticsEnabled, setAnalyticsEnabled] = useState(false) const [autoCheckUpdates, setAutoCheckUpdatesState] = useState(true) const [projectAssetsPath, setProjectAssetsPath] = useState('') - const updateAction = aboutUpdateAction(update.state, onOpenUpdate, onCheckForUpdates) + const isMac = window.electronAPI.platform === 'darwin' + const updateAction = aboutUpdateAction(update.state, onOpenUpdate, onCheckForUpdates, isMac, autoCheckUpdates) // Sync active tab with initialTab prop when modal opens useEffect(() => { @@ -1443,12 +1466,32 @@ export function SettingsModal({ isOpen, onClose, initialTab, update, onOpenUpdat Updates

- {update.state.status === 'available' && `Version ${update.state.version} is available.`} - {update.state.status === 'downloading' && `Downloading… ${update.state.percent ?? 0}%`} - {update.state.status === 'downloaded' && 'Download complete. Restart to apply the update.'} - {update.state.status === 'checking' && 'Checking for updates…'} - {update.state.status === 'not-available' && "You're up to date."} - {update.state.status === 'idle' && 'Check for a newer version, or let the app check automatically.'} + {isMac ? ( + <> + {update.state.status === 'downloading' && `Downloading… ${update.state.percent ?? 0}%`} + {update.state.status === 'downloaded' && ( + autoCheckUpdates + ? 'An update will install when you quit.' + : 'This update is already queued and will still install when you quit.' + )} + {update.state.status === 'checking' && 'Checking for updates…'} + {update.state.status === 'not-available' && "You're up to date."} + {(update.state.status === 'idle' || update.state.status === 'available') && ( + autoCheckUpdates + ? 'New versions download in the background and install when you quit.' + : 'Automatic updates are off. Turn this on to install new versions when you quit.' + )} + + ) : ( + <> + {update.state.status === 'available' && `Version ${update.state.version} is available.`} + {update.state.status === 'downloading' && `Downloading… ${update.state.percent ?? 0}%`} + {update.state.status === 'downloaded' && 'Download complete. Restart to apply the update.'} + {update.state.status === 'checking' && 'Checking for updates…'} + {update.state.status === 'not-available' && "You're up to date."} + {update.state.status === 'idle' && 'Check for a newer version, or let the app check automatically.'} + + )}

{update.state.message && (

{update.state.message}

@@ -1463,9 +1506,13 @@ export function SettingsModal({ isOpen, onClose, initialTab, update, onOpenUpdat
- +

- Periodically check for new versions. You can always check manually above. + {isMac + ? 'When on, new versions download in the background and install when you quit. Check above to look now.' + : 'Periodically check for new versions. You can always check manually above.'}