From 2bb1d7e65b6e93086b0a4420fd6adae03e133a3c Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 27 Jun 2026 14:46:48 +0100 Subject: [PATCH] Verify update installer launch before quitting --- src/main/index.ts | 4 +- .../updates/update-installer-launcher.test.ts | 169 ++++++++++++++++++ src/main/updates/update-installer-launcher.ts | 95 ++++++++++ src/main/updates/update-ipc.test.ts | 19 +- src/main/updates/update-ipc.ts | 4 +- src/main/updates/update-service.test.ts | 106 ++++++++++- src/main/updates/update-service.ts | 44 ++++- src/preload/index.ts | 4 +- src/renderer/SettingsApp.tsx | 16 +- src/renderer/api.d.ts | 4 +- src/renderer/components/SettingsPanel.tsx | 6 + src/renderer/components/UpdatesPanel.tsx | 14 +- src/renderer/updates.test.ts | 42 ++++- src/renderer/updates.ts | 22 ++- src/shared/update.ts | 11 ++ 15 files changed, 531 insertions(+), 29 deletions(-) create mode 100644 src/main/updates/update-installer-launcher.test.ts create mode 100644 src/main/updates/update-installer-launcher.ts diff --git a/src/main/index.ts b/src/main/index.ts index 8101836..5308e07 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -365,7 +365,9 @@ if (!gotSingleInstanceLock) { currentVersion: app.getVersion(), isPackaged: app.isPackaged, platform: process.platform, - autoUpdater + resourcesPath: process.resourcesPath, + autoUpdater, + quitApp }) ); void bluetoothTransport.start(); diff --git a/src/main/updates/update-installer-launcher.test.ts b/src/main/updates/update-installer-launcher.test.ts new file mode 100644 index 0000000..75f9e27 --- /dev/null +++ b/src/main/updates/update-installer-launcher.test.ts @@ -0,0 +1,169 @@ +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + launchWindowsUpdateInstaller, + UPDATE_INSTALLER_ARGS, + type SpawnInstaller, + type SpawnProcess +} from './update-installer-launcher'; + +class FakeSpawnProcess implements SpawnProcess { + pid = 1234; + readonly unref = vi.fn(); + private readonly errorListeners: Array<(error: Error) => void> = []; + private readonly exitListeners: Array<(code: number | null, signal: NodeJS.Signals | null) => void> = []; + + once(event: 'error', listener: (error: Error) => void): void; + once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void; + once( + event: 'error' | 'exit', + listener: ((error: Error) => void) | ((code: number | null, signal: NodeJS.Signals | null) => void) + ): void { + if (event === 'error') { + this.errorListeners.push(listener as (error: Error) => void); + return; + } + + this.exitListeners.push(listener as (code: number | null, signal: NodeJS.Signals | null) => void); + } + + emitError(error = new Error('failed')): void { + for (const listener of this.errorListeners) { + listener(error); + } + } + + emitExit(code: number | null): void { + for (const listener of this.exitListeners) { + listener(code, null); + } + } +} + +describe('launchWindowsUpdateInstaller', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns installer_unavailable when installer path is null', async () => { + await expect(launch({ installerPath: null })).resolves.toEqual({ + ok: false, + reason: 'installer_unavailable' + }); + }); + + it('returns installer_unavailable when installer file is missing', async () => { + await expect( + launch({ + fileExists: (path) => path.endsWith('elevate.exe') + }) + ).resolves.toEqual({ ok: false, reason: 'installer_unavailable' }); + }); + + it('returns elevation_helper_unavailable when elevate.exe is missing', async () => { + await expect( + launch({ + fileExists: (path) => path.endsWith('installer.exe') + }) + ).resolves.toEqual({ ok: false, reason: 'elevation_helper_unavailable' }); + }); + + it('spawns elevate.exe with installer arguments', async () => { + vi.useFakeTimers(); + const child = new FakeSpawnProcess(); + const spawnInstaller = vi.fn(() => child); + + const resultPromise = launch({ child, spawnInstaller, settleMs: 10 }); + await vi.advanceTimersByTimeAsync(10); + + await expect(resultPromise).resolves.toEqual({ ok: true, pid: 1234 }); + expect(spawnInstaller).toHaveBeenCalledWith( + join('resources', 'elevate.exe'), + [join('cache', 'installer.exe'), ...UPDATE_INSTALLER_ARGS], + { + detached: true, + stdio: 'ignore', + windowsHide: false + } + ); + expect(child.unref).toHaveBeenCalledTimes(1); + }); + + it('returns installer_launch_failed if spawn throws', async () => { + await expect( + launch({ + spawnInstaller: () => { + throw new Error('spawn failed'); + } + }) + ).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' }); + }); + + it('returns installer_launch_failed if the child emits error before settling', async () => { + vi.useFakeTimers(); + const child = new FakeSpawnProcess(); + const resultPromise = launch({ child, settleMs: 10 }); + + child.emitError(); + + await expect(resultPromise).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' }); + expect(child.unref).not.toHaveBeenCalled(); + }); + + it('returns installer_launch_failed if the child exits non-zero before settling', async () => { + vi.useFakeTimers(); + const child = new FakeSpawnProcess(); + const resultPromise = launch({ child, settleMs: 10 }); + + child.emitExit(1); + + await expect(resultPromise).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' }); + expect(child.unref).not.toHaveBeenCalled(); + }); + + it('returns success if the child exits cleanly before settling', async () => { + vi.useFakeTimers(); + const child = new FakeSpawnProcess(); + const resultPromise = launch({ child, settleMs: 10 }); + + child.emitExit(0); + + await expect(resultPromise).resolves.toEqual({ ok: true, pid: 1234 }); + expect(child.unref).toHaveBeenCalledTimes(1); + }); + + it('returns success if the child survives the settle period', async () => { + vi.useFakeTimers(); + const child = new FakeSpawnProcess(); + const resultPromise = launch({ child, settleMs: 10 }); + + await vi.advanceTimersByTimeAsync(10); + + await expect(resultPromise).resolves.toEqual({ ok: true, pid: 1234 }); + expect(child.unref).toHaveBeenCalledTimes(1); + }); +}); + +function launch({ + installerPath = join('cache', 'installer.exe'), + resourcesPath = 'resources', + settleMs = 0, + child = new FakeSpawnProcess(), + spawnInstaller = (() => child) as SpawnInstaller, + fileExists = () => true +}: { + installerPath?: string | null; + resourcesPath?: string; + settleMs?: number; + child?: FakeSpawnProcess; + spawnInstaller?: SpawnInstaller; + fileExists?: (path: string) => boolean; +} = {}): Promise extends Promise ? Result : never> { + return launchWindowsUpdateInstaller({ + installerPath, + resourcesPath, + settleMs, + spawnInstaller, + fileExists + }); +} diff --git a/src/main/updates/update-installer-launcher.ts b/src/main/updates/update-installer-launcher.ts new file mode 100644 index 0000000..dccc365 --- /dev/null +++ b/src/main/updates/update-installer-launcher.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; + +export type UpdateInstallerLaunchReason = + | 'installer_unavailable' + | 'elevation_helper_unavailable' + | 'installer_launch_failed'; + +export type UpdateInstallerLaunchResult = + | { ok: true; pid: number | null } + | { ok: false; reason: UpdateInstallerLaunchReason }; + +export type SpawnProcess = { + pid?: number; + unref(): void; + once(event: 'error', listener: (error: Error) => void): void; + once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void; +}; + +export type SpawnInstaller = ( + command: string, + args: string[], + options: { + detached: true; + stdio: 'ignore'; + windowsHide: false; + } +) => SpawnProcess; + +export const UPDATE_INSTALLER_ARGS = ['--updated', '--force-run']; +export const INSTALLER_LAUNCH_SETTLE_MS = 1_500; + +export async function launchWindowsUpdateInstaller({ + installerPath, + resourcesPath, + settleMs = INSTALLER_LAUNCH_SETTLE_MS, + spawnInstaller = spawn as SpawnInstaller, + fileExists = existsSync +}: { + installerPath: string | null; + resourcesPath: string; + settleMs?: number; + spawnInstaller?: SpawnInstaller; + fileExists?: (path: string) => boolean; +}): Promise { + if (!installerPath || !fileExists(installerPath)) { + return { ok: false, reason: 'installer_unavailable' }; + } + + const elevationHelperPath = join(resourcesPath, 'elevate.exe'); + if (!fileExists(elevationHelperPath)) { + return { ok: false, reason: 'elevation_helper_unavailable' }; + } + + let child: SpawnProcess; + try { + child = spawnInstaller(elevationHelperPath, [installerPath, ...UPDATE_INSTALLER_ARGS], { + detached: true, + stdio: 'ignore', + windowsHide: false + }); + } catch { + return { ok: false, reason: 'installer_launch_failed' }; + } + + return new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + complete({ ok: true, pid: child.pid ?? null }); + }, settleMs); + + const complete = (result: UpdateInstallerLaunchResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (result.ok) { + child.unref(); + } + resolve(result); + }; + + child.once('error', () => { + complete({ ok: false, reason: 'installer_launch_failed' }); + }); + child.once('exit', (code) => { + if (code === 0) { + complete({ ok: true, pid: child.pid ?? null }); + return; + } + + complete({ ok: false, reason: 'installer_launch_failed' }); + }); + }); +} diff --git a/src/main/updates/update-ipc.test.ts b/src/main/updates/update-ipc.test.ts index 6d8c91c..904eae0 100644 --- a/src/main/updates/update-ipc.test.ts +++ b/src/main/updates/update-ipc.test.ts @@ -7,6 +7,7 @@ import { type UpdateInstallConfirmation } from './update-ipc'; import type { UpdateService } from './update-service'; +import type { UpdateInstallResult } from '../../shared/update'; type IpcHandler = (event: Electron.IpcMainInvokeEvent, ...args: unknown[]) => unknown; @@ -42,6 +43,20 @@ describe('registerUpdateIpc', () => { expect(updateService.installDownloadedUpdate).toHaveBeenCalledTimes(1); }); + it('returns installer launch failures after confirmation', async () => { + const updateService = createUpdateService({ + downloaded: true, + installResult: { ok: false, reason: 'installer_launch_failed' } + }); + const confirmInstallDownloadedUpdate = vi.fn(async () => true); + + registerUpdateIpc(updateService, { confirmInstallDownloadedUpdate }); + + await expect(invokeInstall()).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' }); + expect(confirmInstallDownloadedUpdate).toHaveBeenCalledTimes(1); + expect(updateService.installDownloadedUpdate).toHaveBeenCalledTimes(1); + }); + it('does not install a downloaded update when confirmation is cancelled', async () => { const updateService = createUpdateService({ downloaded: true, installResult: { ok: true } }); const confirmInstallDownloadedUpdate = vi.fn(async () => false); @@ -91,7 +106,7 @@ function createUpdateService({ installResult }: { downloaded: boolean; - installResult: { ok: boolean; reason?: string }; + installResult: UpdateInstallResult; }): UpdateService { return { getState: vi.fn(() => ({ @@ -112,7 +127,7 @@ function createUpdateService({ })), checkForUpdates: vi.fn(), downloadUpdate: vi.fn(), - installDownloadedUpdate: vi.fn(() => installResult) + installDownloadedUpdate: vi.fn(async () => installResult) } as unknown as UpdateService; } diff --git a/src/main/updates/update-ipc.ts b/src/main/updates/update-ipc.ts index 3257daf..27c3512 100644 --- a/src/main/updates/update-ipc.ts +++ b/src/main/updates/update-ipc.ts @@ -45,13 +45,13 @@ export function registerUpdateIpc( ipcMain.handle(DOWNLOAD_UPDATE_CHANNEL, () => updateService.downloadUpdate()); ipcMain.handle(INSTALL_DOWNLOADED_UPDATE_CHANNEL, async (event) => { if (updateService.getState().download.status !== 'downloaded') { - return updateService.installDownloadedUpdate(); + return await updateService.installDownloadedUpdate(); } if (!(await confirmInstallDownloadedUpdate(event))) { return { ok: false, reason: 'cancelled' }; } - return updateService.installDownloadedUpdate(); + return await updateService.installDownloadedUpdate(); }); } diff --git a/src/main/updates/update-service.test.ts b/src/main/updates/update-service.test.ts index 0322554..5979711 100644 --- a/src/main/updates/update-service.test.ts +++ b/src/main/updates/update-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { UpdateService, type ElectronUpdaterAdapter } from './update-service'; +import type { UpdateInstallerLaunchResult } from './update-installer-launcher'; type Listener = (...args: unknown[]) => void; @@ -9,7 +10,7 @@ class FakeUpdater implements ElectronUpdaterAdapter { readonly quitAndInstall = vi.fn(); readonly listeners = new Map(); checkForUpdates = vi.fn(async () => null); - downloadUpdate = vi.fn(async () => []); + downloadUpdate = vi.fn(async (): Promise => []); on(event: string, listener: Listener): void { const listeners = this.listeners.get(event) ?? []; @@ -130,16 +131,70 @@ describe('UpdateService', () => { expect(state.download.percent).toBe(100); }); - it('does not install before an update is downloaded', () => { + it('does not install before an update is downloaded', async () => { const updater = new FakeUpdater(); const service = createService({ updater }); - expect(service.installDownloadedUpdate()).toEqual({ ok: false, reason: 'not_downloaded' }); + await expect(service.installDownloadedUpdate()).resolves.toEqual({ ok: false, reason: 'not_downloaded' }); expect(updater.quitAndInstall).not.toHaveBeenCalled(); }); - it('installs downloaded updates through the interactive installer and relaunch path', async () => { + it('launches the downloaded installer and quits through the app cleanup path', async () => { const updater = new FakeUpdater(); + const launchInstaller = vi.fn(async (): Promise => ({ ok: true, pid: 1234 })); + const quitApp = vi.fn(); + updater.checkForUpdates.mockImplementation(async () => { + updater.emit('update-available', updateInfo({ version: '0.1.1' })); + return null; + }); + updater.downloadUpdate.mockImplementation(async () => { + updater.emit('update-downloaded', updateInfo({ version: '0.1.1' })); + return ['C:\\Users\\owen\\AppData\\Local\\switchify-pc-updater\\pending\\Switchify-PC-Setup-0.1.1-x64.exe']; + }); + const service = createService({ updater, launchInstaller, quitApp }); + await service.checkForUpdates(); + await service.downloadUpdate(); + + await expect(service.installDownloadedUpdate()).resolves.toEqual({ ok: true }); + expect(launchInstaller).toHaveBeenCalledWith({ + installerPath: 'C:\\Users\\owen\\AppData\\Local\\switchify-pc-updater\\pending\\Switchify-PC-Setup-0.1.1-x64.exe', + resourcesPath: 'C:\\Program Files\\Switchify PC\\resources' + }); + expect(quitApp).toHaveBeenCalledTimes(1); + expect(updater.quitAndInstall).not.toHaveBeenCalled(); + }); + + it('uses the first downloaded path when no exe path is returned', async () => { + const updater = new FakeUpdater(); + const launchInstaller = vi.fn(async (): Promise => ({ ok: true, pid: 1234 })); + updater.checkForUpdates.mockImplementation(async () => { + updater.emit('update-available', updateInfo({ version: '0.1.1' })); + return null; + }); + updater.downloadUpdate.mockImplementation(async () => { + updater.emit('update-downloaded', updateInfo({ version: '0.1.1' })); + return ['C:\\cache\\download.bin']; + }); + const service = createService({ updater, launchInstaller }); + await service.checkForUpdates(); + await service.downloadUpdate(); + + await service.installDownloadedUpdate(); + + expect(launchInstaller).toHaveBeenCalledWith({ + installerPath: 'C:\\cache\\download.bin', + resourcesPath: 'C:\\Program Files\\Switchify PC\\resources' + }); + }); + + it('returns installer_unavailable and keeps running when no installer path was captured', async () => { + const updater = new FakeUpdater(); + const launchInstaller = vi.fn(async (): Promise => ({ + ok: false, + reason: 'installer_unavailable' + })); + const quitApp = vi.fn(); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); updater.checkForUpdates.mockImplementation(async () => { updater.emit('update-available', updateInfo({ version: '0.1.1' })); return null; @@ -148,12 +203,42 @@ describe('UpdateService', () => { updater.emit('update-downloaded', updateInfo({ version: '0.1.1' })); return []; }); - const service = createService({ updater }); + const service = createService({ updater, launchInstaller, quitApp }); + await service.checkForUpdates(); + await service.downloadUpdate(); + + await expect(service.installDownloadedUpdate()).resolves.toEqual({ ok: false, reason: 'installer_unavailable' }); + expect(launchInstaller).toHaveBeenCalledWith({ + installerPath: null, + resourcesPath: 'C:\\Program Files\\Switchify PC\\resources' + }); + expect(quitApp).not.toHaveBeenCalled(); + error.mockRestore(); + }); + + it('returns launcher failures and does not quit the app', async () => { + const updater = new FakeUpdater(); + const launchInstaller = vi.fn(async (): Promise => ({ + ok: false, + reason: 'installer_launch_failed' + })); + const quitApp = vi.fn(); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + updater.checkForUpdates.mockImplementation(async () => { + updater.emit('update-available', updateInfo({ version: '0.1.1' })); + return null; + }); + updater.downloadUpdate.mockImplementation(async () => { + updater.emit('update-downloaded', updateInfo({ version: '0.1.1' })); + return ['C:\\cache\\installer.exe']; + }); + const service = createService({ updater, launchInstaller, quitApp }); await service.checkForUpdates(); await service.downloadUpdate(); - expect(service.installDownloadedUpdate()).toEqual({ ok: true }); - expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true); + await expect(service.installDownloadedUpdate()).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' }); + expect(quitApp).not.toHaveBeenCalled(); + error.mockRestore(); }); it('maps updater errors during checks to check_failed', async () => { @@ -192,10 +277,14 @@ describe('UpdateService', () => { function createService({ updater = new FakeUpdater(), + launchInstaller = vi.fn(async (): Promise => ({ ok: true, pid: 1234 })), + quitApp = vi.fn(), isPackaged = true, platform = 'win32' }: { updater?: FakeUpdater; + launchInstaller?: typeof import('./update-installer-launcher').launchWindowsUpdateInstaller; + quitApp?: () => void; isPackaged?: boolean; platform?: NodeJS.Platform; } = {}): UpdateService { @@ -203,7 +292,10 @@ function createService({ currentVersion: '0.1.0', isPackaged, platform, + resourcesPath: 'C:\\Program Files\\Switchify PC\\resources', autoUpdater: updater, + launchInstaller, + quitApp, now: () => new Date('2026-06-12T12:00:00.000Z') }); } diff --git a/src/main/updates/update-service.ts b/src/main/updates/update-service.ts index 4cffece..bc26a05 100644 --- a/src/main/updates/update-service.ts +++ b/src/main/updates/update-service.ts @@ -1,7 +1,8 @@ import { autoUpdater as defaultAutoUpdater } from 'electron-updater'; import type { UpdateInfo as ElectronUpdateInfo, UpdateCheckResult } from 'electron-updater'; -import type { UpdateDownloadProgress, UpdateInfo, UpdateState } from '../../shared/update'; +import type { UpdateDownloadProgress, UpdateInfo, UpdateInstallResult, UpdateState } from '../../shared/update'; import { createInitialUpdateState } from '../../shared/update'; +import { launchWindowsUpdateInstaller } from './update-installer-launcher'; type ElectronDownloadProgress = { transferred?: number; @@ -14,7 +15,6 @@ export type ElectronUpdaterAdapter = { autoInstallOnAppQuit: boolean; checkForUpdates(): Promise; downloadUpdate(): Promise>; - quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void; on(event: string, listener: (...args: unknown[]) => void): void; }; @@ -22,29 +22,36 @@ export type UpdateServiceOptions = { currentVersion: string; isPackaged: boolean; platform: NodeJS.Platform; + resourcesPath: string; autoUpdater?: ElectronUpdaterAdapter; + launchInstaller?: typeof launchWindowsUpdateInstaller; + quitApp?: () => void; now?: () => Date; }; type UpdateOperation = 'idle' | 'checking' | 'downloading'; -const INSTALL_UPDATE_SILENTLY = false; -const FORCE_RUN_AFTER_INSTALL = true; - export class UpdateService { private readonly isPackaged: boolean; private readonly platform: NodeJS.Platform; + private readonly resourcesPath: string; private readonly autoUpdater: ElectronUpdaterAdapter; + private readonly launchInstaller: typeof launchWindowsUpdateInstaller; + private readonly quitApp: () => void; private readonly now: () => Date; private state: UpdateState; private operation: UpdateOperation = 'idle'; private checkingPromise: Promise | null = null; private downloadPromise: Promise | null = null; + private downloadedInstallerPath: string | null = null; constructor(options: UpdateServiceOptions) { this.isPackaged = options.isPackaged; this.platform = options.platform; + this.resourcesPath = options.resourcesPath; this.autoUpdater = options.autoUpdater ?? defaultAutoUpdater; + this.launchInstaller = options.launchInstaller ?? launchWindowsUpdateInstaller; + this.quitApp = options.quitApp ?? (() => undefined); this.now = options.now ?? (() => new Date()); this.state = createInitialUpdateState(options.currentVersion); @@ -76,6 +83,7 @@ export class UpdateService { if (this.checkingPromise) return Promise.resolve(this.getState()); this.operation = 'checking'; + this.downloadedInstallerPath = null; this.state = { info: { ...this.state.info, @@ -154,9 +162,13 @@ export class UpdateService { this.downloadPromise = this.autoUpdater .downloadUpdate() - .then(() => this.getState()) + .then((downloadedPaths) => { + this.downloadedInstallerPath = firstInstallerPath(downloadedPaths); + return this.getState(); + }) .catch((error) => { console.error('Switchify update download failed.', error); + this.downloadedInstallerPath = null; this.state = { ...this.state, download: { @@ -175,7 +187,7 @@ export class UpdateService { return this.downloadPromise; } - installDownloadedUpdate(): { ok: boolean; reason?: string } { + async installDownloadedUpdate(): Promise { const unsupportedReason = this.unsupportedReason(); if (unsupportedReason) { return { ok: false, reason: unsupportedReason }; @@ -185,13 +197,24 @@ export class UpdateService { return { ok: false, reason: 'not_downloaded' }; } - this.autoUpdater.quitAndInstall(INSTALL_UPDATE_SILENTLY, FORCE_RUN_AFTER_INSTALL); + const result = await this.launchInstaller({ + installerPath: this.downloadedInstallerPath, + resourcesPath: this.resourcesPath + }); + + if (!result.ok) { + console.error('Switchify update installer could not be started.', result.reason); + return { ok: false, reason: result.reason }; + } + + this.quitApp(); return { ok: true }; } private registerUpdaterEvents(): void { this.autoUpdater.on('update-available', (rawInfo) => { const info = rawInfo as ElectronUpdateInfo; + this.downloadedInstallerPath = null; this.state = { info: updateInfo(this.state.info, info, { checkedAt: this.now().toISOString(), @@ -255,6 +278,7 @@ export class UpdateService { this.autoUpdater.on('error', (error) => { console.error('Switchify updater error.', error); if (this.operation === 'downloading') { + this.downloadedInstallerPath = null; this.state = { ...this.state, download: { @@ -285,6 +309,10 @@ export class UpdateService { } } +function firstInstallerPath(paths: string[]): string | null { + return paths.find((path) => path.toLowerCase().endsWith('.exe')) ?? paths[0] ?? null; +} + function updateInfo( previous: UpdateInfo, info: ElectronUpdateInfo, diff --git a/src/preload/index.ts b/src/preload/index.ts index 689fa9f..bfb487c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,7 +30,7 @@ import { } from '../shared/ipc-channels'; import type { SettingsSectionId } from '../shared/settings'; import type { SystemStartupSettings } from '../shared/system-startup'; -import type { UpdateState } from '../shared/update'; +import type { UpdateInstallResult, UpdateState } from '../shared/update'; contextBridge.exposeInMainWorld('switchifyPc', { appName: 'Switchify PC', @@ -74,6 +74,6 @@ contextBridge.exposeInMainWorld('switchifyPc', { ipcRenderer.invoke(GET_SYSTEM_STARTUP_SETTINGS_CHANNEL), setStartWithSystem: (enabled: boolean): Promise => ipcRenderer.invoke(SET_START_WITH_SYSTEM_CHANNEL, enabled), - installDownloadedUpdate: (): Promise<{ ok: boolean; reason?: string }> => + installDownloadedUpdate: (): Promise => ipcRenderer.invoke(INSTALL_DOWNLOADED_UPDATE_CHANNEL) }); diff --git a/src/renderer/SettingsApp.tsx b/src/renderer/SettingsApp.tsx index a6483d2..53b0a7d 100644 --- a/src/renderer/SettingsApp.tsx +++ b/src/renderer/SettingsApp.tsx @@ -4,6 +4,7 @@ import type { UpdateState } from '../shared/update'; import { SettingsView } from './components/SettingsPanel'; import { WindowChrome } from './components/WindowTitleBar'; import { settingsSectionFromHash } from './settings-route'; +import { updateInstallMessage } from './updates'; import { useSwitchifyPcStatus } from './useSwitchifyPcStatus'; export function SettingsApp(): ReactElement { @@ -13,6 +14,8 @@ export function SettingsApp(): ReactElement { const [systemStartupSettings, setSystemStartupSettings] = useState(null); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); const [isDownloadingUpdate, setIsDownloadingUpdate] = useState(false); + const [isInstallingUpdate, setIsInstallingUpdate] = useState(false); + const [updateInstallError, setUpdateInstallError] = useState(null); const [isUpdatingSystemStartup, setIsUpdatingSystemStartup] = useState(false); useEffect(() => { @@ -67,7 +70,16 @@ export function SettingsApp(): ReactElement { }, [bridge]); const installDownloadedUpdate = useCallback(async (): Promise => { - await bridge.installDownloadedUpdate(); + setIsInstallingUpdate(true); + setUpdateInstallError(null); + try { + const result = await bridge.installDownloadedUpdate(); + if (!result.ok) { + setUpdateInstallError(updateInstallMessage(result.reason)); + } + } finally { + setIsInstallingUpdate(false); + } }, [bridge]); const setStartWithSystem = useCallback( @@ -110,6 +122,8 @@ export function SettingsApp(): ReactElement { updateState={updateState} isCheckingForUpdates={isCheckingForUpdates} isDownloadingUpdate={isDownloadingUpdate} + isInstallingUpdate={isInstallingUpdate} + updateInstallError={updateInstallError} initialSection={settingsSectionFromHash(window.location.hash)} onSettingsSectionRequest={bridge.onShowSettingsSection} onCheckForUpdates={checkForUpdates} diff --git a/src/renderer/api.d.ts b/src/renderer/api.d.ts index 86c8a9d..b2cbc64 100644 --- a/src/renderer/api.d.ts +++ b/src/renderer/api.d.ts @@ -6,7 +6,7 @@ import type { CursorOverlaySettings } from '../shared/cursor-overlay-settings'; import type { PointerMovementSettings } from '../shared/pointer-movement-settings'; import type { SettingsSectionId } from '../shared/settings'; import type { SystemStartupSettings } from '../shared/system-startup'; -import type { UpdateState } from '../shared/update'; +import type { UpdateInstallResult, UpdateState } from '../shared/update'; declare global { interface Window { @@ -41,7 +41,7 @@ declare global { downloadUpdate: () => Promise; getSystemStartupSettings: () => Promise; setStartWithSystem: (enabled: boolean) => Promise; - installDownloadedUpdate: () => Promise<{ ok: boolean; reason?: string }>; + installDownloadedUpdate: () => Promise; }; } } diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 9a1924b..7b74850 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -41,6 +41,8 @@ type SettingsViewProps = { updateState: UpdateState | null; isCheckingForUpdates: boolean; isDownloadingUpdate: boolean; + isInstallingUpdate: boolean; + updateInstallError: string | null; initialSection: SettingsSectionId; onSettingsSectionRequest?: (handler: (section: SettingsSectionId) => void) => () => void; onCheckForUpdates: () => Promise; @@ -75,6 +77,8 @@ export function SettingsView({ updateState, isCheckingForUpdates, isDownloadingUpdate, + isInstallingUpdate, + updateInstallError, initialSection, onSettingsSectionRequest, onCheckForUpdates, @@ -131,6 +135,8 @@ export function SettingsView({ state={updateState} isChecking={isCheckingForUpdates} isDownloading={isDownloadingUpdate} + isInstalling={isInstallingUpdate} + installError={updateInstallError} onCheck={onCheckForUpdates} onDownload={onDownloadUpdate} onInstallDownloaded={onInstallDownloadedUpdate} diff --git a/src/renderer/components/UpdatesPanel.tsx b/src/renderer/components/UpdatesPanel.tsx index 111bb8d..5eb6b16 100644 --- a/src/renderer/components/UpdatesPanel.tsx +++ b/src/renderer/components/UpdatesPanel.tsx @@ -6,6 +6,8 @@ type UpdatesPanelProps = { state: UpdateState | null; isChecking: boolean; isDownloading: boolean; + isInstalling: boolean; + installError: string | null; onCheck: () => Promise; onDownload: () => Promise; onInstallDownloaded: () => Promise; @@ -15,6 +17,8 @@ export function UpdatesPanel({ state, isChecking, isDownloading, + isInstalling, + installError, onCheck, onDownload, onInstallDownloaded @@ -44,6 +48,7 @@ export function UpdatesPanel({ ) : null} {downloadMessage ?
{downloadMessage}
: null} + {installError ?
{installError}
: null}
) : null} {showInstallButton ? ( - ) : null}
diff --git a/src/renderer/updates.test.ts b/src/renderer/updates.test.ts index 1c7ffa7..bdc4617 100644 --- a/src/renderer/updates.test.ts +++ b/src/renderer/updates.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; import type { UpdateDownloadProgress, UpdateInfo, UpdateState } from '../shared/update'; -import { canDownloadUpdate, updateCheckMessage, updateDownloadMessage, updateIndicatorState } from './updates'; +import { + canDownloadUpdate, + updateCheckMessage, + updateDownloadMessage, + updateIndicatorState, + updateInstallMessage +} from './updates'; describe('updateCheckMessage', () => { it('formats not checked text as a terminal sentence', () => { @@ -107,6 +113,40 @@ describe('canDownloadUpdate', () => { }); }); +describe('updateInstallMessage', () => { + it('does not show a message for cancel or no error', () => { + expect(updateInstallMessage(null)).toBeNull(); + expect(updateInstallMessage('cancelled')).toBeNull(); + }); + + it('explains when the update is not downloaded', () => { + expect(updateInstallMessage('not_downloaded')).toBe('The update is not downloaded yet.'); + }); + + it('explains unsupported install environments', () => { + expect(updateInstallMessage('not_packaged')).toBe('Updates are only available in the installed app.'); + expect(updateInstallMessage('not_supported')).toBe('Updates are only supported on Windows.'); + }); + + it('explains missing installer files', () => { + expect(updateInstallMessage('installer_unavailable')).toBe( + 'The downloaded installer could not be found. Download the update again.' + ); + }); + + it('explains missing elevation support', () => { + expect(updateInstallMessage('elevation_helper_unavailable')).toBe( + 'The update installer could not request permission to install. Reinstall Switchify PC from the latest installer.' + ); + }); + + it('explains installer launch failures', () => { + expect(updateInstallMessage('installer_launch_failed')).toBe( + 'The update installer could not be started. Download the update again or run the installer manually.' + ); + }); +}); + describe('updateIndicatorState', () => { it('hides the indicator without update state', () => { expect(updateIndicatorState(null)).toBe('hidden'); diff --git a/src/renderer/updates.ts b/src/renderer/updates.ts index 26022b4..7e3da6a 100644 --- a/src/renderer/updates.ts +++ b/src/renderer/updates.ts @@ -1,4 +1,4 @@ -import type { UpdateDownloadProgress, UpdateInfo, UpdateState } from '../shared/update'; +import type { UpdateDownloadProgress, UpdateInfo, UpdateInstallFailureReason, UpdateState } from '../shared/update'; export type UpdateIndicatorState = 'hidden' | 'available' | 'downloaded'; @@ -39,6 +39,26 @@ export function updateDownloadMessage(download: UpdateDownloadProgress | null): return 'Could not download the update.'; } +export function updateInstallMessage(reason: UpdateInstallFailureReason | null): string | null { + switch (reason) { + case null: + case 'cancelled': + return null; + case 'not_downloaded': + return 'The update is not downloaded yet.'; + case 'not_packaged': + return 'Updates are only available in the installed app.'; + case 'not_supported': + return 'Updates are only supported on Windows.'; + case 'installer_unavailable': + return 'The downloaded installer could not be found. Download the update again.'; + case 'elevation_helper_unavailable': + return 'The update installer could not request permission to install. Reinstall Switchify PC from the latest installer.'; + case 'installer_launch_failed': + return 'The update installer could not be started. Download the update again or run the installer manually.'; + } +} + export function canDownloadUpdate(state: UpdateState | null): boolean { return ( state?.info.status === 'update_available' && diff --git a/src/shared/update.ts b/src/shared/update.ts index 16e3364..4201333 100644 --- a/src/shared/update.ts +++ b/src/shared/update.ts @@ -34,6 +34,17 @@ export type UpdateState = { download: UpdateDownloadProgress; }; +export type UpdateInstallFailureReason = + | 'not_downloaded' + | 'not_packaged' + | 'not_supported' + | 'cancelled' + | 'installer_unavailable' + | 'elevation_helper_unavailable' + | 'installer_launch_failed'; + +export type UpdateInstallResult = { ok: true } | { ok: false; reason: UpdateInstallFailureReason }; + export function createInitialUpdateState(currentVersion: string): UpdateState { return { info: {