Skip to content
Merged
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
105 changes: 94 additions & 11 deletions electron/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setInterval> | 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<UpdateStatePayload>): void {
state = { ...state, ...patch }
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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<void> {
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()
Expand Down Expand Up @@ -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' })
}
}
65 changes: 56 additions & 9 deletions frontend/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -1443,12 +1466,32 @@ export function SettingsModal({ isOpen, onClose, initialTab, update, onOpenUpdat
<span className="text-sm font-medium text-white">Updates</span>
</div>
<p className="text-xs text-zinc-400">
{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.'}
</>
)}
</p>
{update.state.message && (
<p className="text-xs text-red-400">{update.state.message}</p>
Expand All @@ -1463,9 +1506,13 @@ export function SettingsModal({ isOpen, onClose, initialTab, update, onOpenUpdat
</Button>
<div className="flex items-start justify-between gap-4 border-t border-zinc-700/50 pt-3">
<div className="flex-1">
<label className="text-sm font-medium text-white">Automatically check for updates</label>
<label className="text-sm font-medium text-white">
{isMac ? 'Automatic updates' : 'Automatically check for updates'}
</label>
<p className="text-xs text-zinc-500 leading-relaxed">
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.'}
</p>
</div>
<button
Expand Down
7 changes: 6 additions & 1 deletion frontend/hooks/use-app-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export function useAppUpdateModal() {
const [laterVersion, setLaterVersion] = useState<string | null>(null)

useEffect(() => {
// Mac has no modal: silent download + install-on-quit, gated only by the About toggle.
if (window.electronAPI.platform === 'darwin') return
const s = update.state
if (s.status === 'available') {
if (manualCheckPending || s.version !== laterVersion) {
Expand Down Expand Up @@ -103,7 +105,10 @@ export function useAppUpdateModal() {
// Keep the modal mounted across a periodic re-check (`checking`) so it does not
// unmount/remount. Do not treat `available` as busy in main — that would hide a
// newer version after the user clicked Later.
isModalOpen: modalOpen && (MODAL_STATUSES.has(status) || status === 'checking'),
isModalOpen:
window.electronAPI.platform !== 'darwin'
&& modalOpen
&& (MODAL_STATUSES.has(status) || status === 'checking'),
openModal,
closeModal,
checkForUpdates,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ltx-desktop",
"version": "1.2.2",
"version": "1.2.3",
"description": "LTX-2 Video Generation - Desktop App",
"type": "module",
"main": "dist-electron/main.js",
Expand Down
Loading