diff --git a/src/main/index.ts b/src/main/index.ts index e4b7015..8fadfeb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,8 +3,9 @@ import { autoUpdater } from 'electron-updater'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { DEFAULT_BLUETOOTH_STATUS } from '../shared/bluetooth-status'; -import { SHOW_SETTINGS_SECTION_CHANNEL } from '../shared/ipc-channels'; +import { SHOW_SETTINGS_SECTION_CHANNEL, UPDATE_STATE_CHANGED_CHANNEL } from '../shared/ipc-channels'; import type { SettingsSectionId } from '../shared/settings'; +import type { UpdateState } from '../shared/update'; import { WindowsBluetoothTransport } from './bluetooth/bluetooth-transport'; import { ControlService } from './control/control-service'; import { CursorOverlay } from './cursor-overlay'; @@ -42,6 +43,7 @@ let tray: SwitchifyTray | null = null; let cursorOverlay: CursorOverlay | null = null; let bluetoothTransport: WindowsBluetoothTransport | null = null; let releaseHeldMouseButtons: (() => Promise) | null = null; +let updateService: UpdateService | null = null; let isQuitting = false; if (process.platform === 'win32' && app.isPackaged) { @@ -240,6 +242,14 @@ function quitApp(): void { app.quit(); } +function broadcastUpdateState(state: UpdateState): void { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(UPDATE_STATE_CHANGED_CHANNEL, state); + } + } +} + if (!gotSingleInstanceLock) { app.quit(); } else { @@ -360,17 +370,18 @@ if (!gotSingleInstanceLock) { registerAppWindowIpc(); registerExternalUrlIpc(); registerSystemStartupIpc(systemStartup); - registerUpdateIpc( - new UpdateService({ - currentVersion: app.getVersion(), - isPackaged: app.isPackaged, - platform: process.platform, - resourcesPath: process.resourcesPath, - diagnosticsFilePath: join(app.getPath('userData'), 'update-install-diagnostics.jsonl'), - autoUpdater, - quitApp - }) - ); + updateService = new UpdateService({ + currentVersion: app.getVersion(), + isPackaged: app.isPackaged, + platform: process.platform, + resourcesPath: process.resourcesPath, + diagnosticsFilePath: join(app.getPath('userData'), 'update-install-diagnostics.jsonl'), + autoUpdater, + quitApp, + onStateChanged: broadcastUpdateState + }); + registerUpdateIpc(updateService); + updateService.startAutomaticUpdateChecks(); void bluetoothTransport.start(); mainWindow = createMainWindow({ showOnReady: !startHidden }); @@ -407,6 +418,7 @@ if (!gotSingleInstanceLock) { event.preventDefault(); isQuitting = true; + updateService?.stopAutomaticUpdateChecks(); void (releaseHeldMouseButtons?.() ?? Promise.resolve()) .catch((error) => { console.warn(error instanceof Error ? error.message : 'Could not release held mouse buttons.'); @@ -419,6 +431,7 @@ if (!gotSingleInstanceLock) { bluetoothTransport?.stop(); bluetoothTransport = null; releaseHeldMouseButtons = null; + updateService = null; controlService = null; }) .finally(() => { diff --git a/src/main/updates/update-service.test.ts b/src/main/updates/update-service.test.ts index 0a0f3b8..63ccb67 100644 --- a/src/main/updates/update-service.test.ts +++ b/src/main/updates/update-service.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { UpdateService, type ElectronUpdaterAdapter } from './update-service'; +import { + INITIAL_UPDATE_POLL_DELAY_MS, + UPDATE_POLL_INTERVAL_MS, + UpdateService, + type ElectronUpdaterAdapter +} from './update-service'; import type { UpdateInstallerLaunchResult } from './update-installer-launcher'; type Listener = (...args: unknown[]) => void; @@ -29,6 +34,10 @@ class FakeUpdater implements ElectronUpdaterAdapter { } describe('UpdateService', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('disables automatic download and install-on-quit', () => { const updater = new FakeUpdater(); createService({ updater }); @@ -55,6 +64,141 @@ describe('UpdateService', () => { expect(state.info.reason).toBe('not_supported'); }); + it('starts automatic update checks after the initial delay', async () => { + vi.useFakeTimers(); + const updater = new FakeUpdater(); + const service = createService({ updater }); + + service.startAutomaticUpdateChecks(); + await vi.advanceTimersByTimeAsync(INITIAL_UPDATE_POLL_DELAY_MS - 1); + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + expect(updater.checkForUpdates).toHaveBeenCalledTimes(1); + }); + + it('checks for updates every hour', async () => { + vi.useFakeTimers(); + const updater = new FakeUpdater(); + const service = createService({ updater }); + + service.startAutomaticUpdateChecks(); + await vi.advanceTimersByTimeAsync(INITIAL_UPDATE_POLL_DELAY_MS); + + expect(updater.checkForUpdates).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS); + + expect(updater.checkForUpdates).toHaveBeenCalledTimes(2); + }); + + it('does not start automatic checks in unpackaged builds', async () => { + vi.useFakeTimers(); + const updater = new FakeUpdater(); + const service = createService({ updater, isPackaged: false }); + + service.startAutomaticUpdateChecks(); + await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS * 2); + + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('does not start automatic checks on non-Windows platforms', async () => { + vi.useFakeTimers(); + const updater = new FakeUpdater(); + const service = createService({ updater, platform: 'darwin' }); + + service.startAutomaticUpdateChecks(); + await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS * 2); + + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('does not start duplicate polling timers', async () => { + vi.useFakeTimers(); + const updater = new FakeUpdater(); + const service = createService({ updater }); + + service.startAutomaticUpdateChecks(); + service.startAutomaticUpdateChecks(); + await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS); + + expect(updater.checkForUpdates).toHaveBeenCalledTimes(2); + }); + + it('stops automatic update checks', async () => { + vi.useFakeTimers(); + const updater = new FakeUpdater(); + const service = createService({ updater }); + + service.startAutomaticUpdateChecks(); + service.stopAutomaticUpdateChecks(); + await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS * 2); + + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('automatic checks skip while another operation is active', async () => { + vi.useFakeTimers(); + const updater = new FakeUpdater(); + let resolveCheck: () => void = () => undefined; + updater.checkForUpdates.mockImplementation(() => new Promise((resolve) => { + resolveCheck = () => resolve(null); + })); + const service = createService({ updater }); + + service.startAutomaticUpdateChecks(); + await vi.advanceTimersByTimeAsync(INITIAL_UPDATE_POLL_DELAY_MS); + await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS); + + expect(updater.checkForUpdates).toHaveBeenCalledTimes(1); + resolveCheck(); + }); + + it('automatic checks skip when an update is already downloaded', async () => { + vi.useFakeTimers(); + const updater = new FakeUpdater(); + 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 }); + await service.checkForUpdates(); + await service.downloadUpdate(); + updater.checkForUpdates.mockClear(); + + service.startAutomaticUpdateChecks(); + await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS); + + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('notifies listeners when update state changes', async () => { + const updater = new FakeUpdater(); + const onStateChanged = vi.fn(); + updater.checkForUpdates.mockImplementation(async () => { + updater.emit('update-available', updateInfo({ version: '0.1.1' })); + return null; + }); + const service = createService({ updater, onStateChanged }); + + await service.checkForUpdates(); + + expect(onStateChanged).toHaveBeenCalledWith( + expect.objectContaining({ + info: expect.objectContaining({ + status: 'update_available', + latestVersion: '0.1.1' + }) + }) + ); + }); + it('maps update-not-available to up_to_date', async () => { const updater = new FakeUpdater(); updater.checkForUpdates.mockImplementation(async () => { @@ -396,6 +540,7 @@ function createService({ quitApp = vi.fn(), diagnosticsFilePath = null, removeFile = vi.fn(async () => undefined), + onStateChanged = vi.fn(), isPackaged = true, platform = 'win32' }: { @@ -404,6 +549,7 @@ function createService({ quitApp?: () => void; diagnosticsFilePath?: string | null; removeFile?: (path: string) => Promise; + onStateChanged?: (state: import('../../shared/update').UpdateState) => void; isPackaged?: boolean; platform?: NodeJS.Platform; } = {}): UpdateService { @@ -417,6 +563,7 @@ function createService({ quitApp, diagnosticsFilePath, removeFile, + onStateChanged, 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 545793b..49d3365 100644 --- a/src/main/updates/update-service.ts +++ b/src/main/updates/update-service.ts @@ -32,10 +32,18 @@ export type UpdateServiceOptions = { now?: () => Date; diagnosticsFilePath?: string | null; removeFile?: (path: string) => Promise; + setInterval?: typeof setInterval; + clearInterval?: typeof clearInterval; + setTimeout?: typeof setTimeout; + clearTimeout?: typeof clearTimeout; + onStateChanged?: (state: UpdateState) => void; }; type UpdateOperation = 'idle' | 'checking' | 'downloading'; +export const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000; +export const INITIAL_UPDATE_POLL_DELAY_MS = 30 * 1000; + export class UpdateService { private readonly isPackaged: boolean; private readonly platform: NodeJS.Platform; @@ -46,11 +54,18 @@ export class UpdateService { private readonly now: () => Date; private readonly diagnosticsFilePath: string | null; private readonly removeFile: (path: string) => Promise; + private readonly setPollInterval: typeof setInterval; + private readonly clearPollInterval: typeof clearInterval; + private readonly setPollTimeout: typeof setTimeout; + private readonly clearPollTimeout: typeof clearTimeout; + private readonly notifyStateChanged: (state: UpdateState) => void; private state: UpdateState; private operation: UpdateOperation = 'idle'; private checkingPromise: Promise | null = null; private downloadPromise: Promise | null = null; private downloadedInstallerPath: string | null = null; + private pollInterval: NodeJS.Timeout | null = null; + private initialPollTimeout: NodeJS.Timeout | null = null; constructor(options: UpdateServiceOptions) { this.isPackaged = options.isPackaged; @@ -62,6 +77,11 @@ export class UpdateService { this.now = options.now ?? (() => new Date()); this.diagnosticsFilePath = options.diagnosticsFilePath ?? null; this.removeFile = options.removeFile ?? ((path) => rm(path, { force: true })); + this.setPollInterval = options.setInterval ?? setInterval; + this.clearPollInterval = options.clearInterval ?? clearInterval; + this.setPollTimeout = options.setTimeout ?? setTimeout; + this.clearPollTimeout = options.clearTimeout ?? clearTimeout; + this.notifyStateChanged = options.onStateChanged ?? (() => undefined); this.state = createInitialUpdateState(options.currentVersion); this.autoUpdater.autoDownload = false; @@ -73,10 +93,36 @@ export class UpdateService { return cloneState(this.state); } + startAutomaticUpdateChecks(): void { + if (this.unsupportedReason()) return; + if (this.pollInterval || this.initialPollTimeout) return; + + this.initialPollTimeout = this.setPollTimeout(() => { + this.initialPollTimeout = null; + void this.runAutomaticUpdateCheck(); + }, INITIAL_UPDATE_POLL_DELAY_MS); + + this.pollInterval = this.setPollInterval(() => { + void this.runAutomaticUpdateCheck(); + }, UPDATE_POLL_INTERVAL_MS); + } + + stopAutomaticUpdateChecks(): void { + if (this.initialPollTimeout) { + this.clearPollTimeout(this.initialPollTimeout); + this.initialPollTimeout = null; + } + + if (this.pollInterval) { + this.clearPollInterval(this.pollInterval); + this.pollInterval = null; + } + } + checkForUpdates(): Promise { const unsupportedReason = this.unsupportedReason(); if (unsupportedReason) { - this.state = { + this.setState({ ...this.state, info: { ...this.state.info, @@ -85,7 +131,7 @@ export class UpdateService { reason: unsupportedReason }, download: createIdleDownload() - }; + }); return Promise.resolve(this.getState()); } @@ -93,7 +139,7 @@ export class UpdateService { this.operation = 'checking'; this.downloadedInstallerPath = null; - this.state = { + this.setState({ info: { ...this.state.info, latestVersion: null, @@ -104,14 +150,14 @@ export class UpdateService { reason: undefined }, download: createIdleDownload() - }; + }); this.checkingPromise = this.autoUpdater .checkForUpdates() .then(() => this.getState()) .catch((error) => { console.error('Switchify update check failed.', error); - this.state = { + this.setState({ ...this.state, info: { ...this.state.info, @@ -119,7 +165,7 @@ export class UpdateService { status: 'check_failed', reason: 'network_error' } - }; + }); return this.getState(); }) .finally(() => { @@ -133,34 +179,34 @@ export class UpdateService { async downloadUpdate(): Promise { const unsupportedReason = this.unsupportedReason(); if (unsupportedReason) { - this.state = { + this.setState({ ...this.state, download: { ...createIdleDownload(), status: 'download_failed', reason: unsupportedReason } - }; + }); return this.getState(); } if (this.downloadPromise) return this.getState(); if (this.state.info.status !== 'update_available') { - this.state = { + this.setState({ ...this.state, download: { ...createIdleDownload(), status: 'download_failed', reason: 'not_available' } - }; + }); return this.getState(); } this.operation = 'downloading'; this.downloadedInstallerPath = null; - this.state = { + this.setState({ ...this.state, download: { status: 'downloading', @@ -168,7 +214,7 @@ export class UpdateService { totalBytes: null, percent: null } - }; + }); this.downloadPromise = this.autoUpdater .downloadUpdate() @@ -179,14 +225,14 @@ export class UpdateService { .catch((error) => { console.error('Switchify update download failed.', error); this.downloadedInstallerPath = null; - this.state = { + this.setState({ ...this.state, download: { ...this.state.download, status: 'download_failed', reason: 'network_error' } - }; + }); return this.getState(); }) .finally(() => { @@ -233,20 +279,20 @@ export class UpdateService { this.autoUpdater.on('update-available', (rawInfo) => { const info = rawInfo as ElectronUpdateInfo; this.downloadedInstallerPath = null; - this.state = { + this.setState({ info: updateInfo(this.state.info, info, { checkedAt: this.now().toISOString(), status: 'update_available', reason: undefined }), download: createIdleDownload() - }; + }); }); this.autoUpdater.on('update-not-available', (rawInfo) => { const info = rawInfo as ElectronUpdateInfo; this.downloadedInstallerPath = null; - this.state = { + this.setState({ ...this.state, info: updateInfo(this.state.info, info, { checkedAt: this.now().toISOString(), @@ -255,7 +301,7 @@ export class UpdateService { reason: undefined }), download: createIdleDownload() - }; + }); }); this.autoUpdater.on('download-progress', (rawProgress) => { @@ -269,7 +315,7 @@ export class UpdateService { ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) : null; - this.state = { + this.setState({ ...this.state, download: { status: 'downloading', @@ -277,12 +323,12 @@ export class UpdateService { totalBytes, percent } - }; + }); }); this.autoUpdater.on('update-downloaded', (rawInfo) => { const info = rawInfo as ElectronUpdateInfo; - this.state = { + this.setState({ ...this.state, info: updateInfo(this.state.info, info), download: { @@ -291,25 +337,25 @@ export class UpdateService { totalBytes: this.state.download.totalBytes, percent: 100 } - }; + }); }); this.autoUpdater.on('error', (error) => { console.error('Switchify updater error.', error); if (this.operation === 'downloading') { this.downloadedInstallerPath = null; - this.state = { + this.setState({ ...this.state, download: { ...this.state.download, status: 'download_failed', reason: 'network_error' } - }; + }); return; } - this.state = { + this.setState({ ...this.state, info: { ...this.state.info, @@ -317,10 +363,22 @@ export class UpdateService { status: 'check_failed', reason: 'network_error' } - }; + }); }); } + private async runAutomaticUpdateCheck(): Promise { + if (this.operation !== 'idle') return; + if (this.state.download.status === 'downloading' || this.state.download.status === 'downloaded') return; + + await this.checkForUpdates(); + } + + private setState(state: UpdateState): void { + this.state = state; + this.notifyStateChanged(this.getState()); + } + private unsupportedReason(): 'not_packaged' | 'not_supported' | null { if (!this.isPackaged) return 'not_packaged'; if (this.platform !== 'win32') return 'not_supported'; diff --git a/src/preload/index.ts b/src/preload/index.ts index bfb487c..a3838c8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -27,6 +27,7 @@ import { SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, SET_START_WITH_SYSTEM_CHANNEL, SHOW_SETTINGS_SECTION_CHANNEL, + UPDATE_STATE_CHANGED_CHANNEL, } from '../shared/ipc-channels'; import type { SettingsSectionId } from '../shared/settings'; import type { SystemStartupSettings } from '../shared/system-startup'; @@ -70,6 +71,11 @@ contextBridge.exposeInMainWorld('switchifyPc', { getUpdateState: (): Promise => ipcRenderer.invoke(GET_UPDATE_STATE_CHANNEL), checkForUpdates: (): Promise => ipcRenderer.invoke(CHECK_FOR_UPDATES_CHANNEL), downloadUpdate: (): Promise => ipcRenderer.invoke(DOWNLOAD_UPDATE_CHANNEL), + onUpdateStateChanged: (handler: (state: UpdateState) => void): (() => void) => { + const listener = (_event: IpcRendererEvent, state: UpdateState): void => handler(state); + ipcRenderer.on(UPDATE_STATE_CHANGED_CHANNEL, listener); + return () => ipcRenderer.removeListener(UPDATE_STATE_CHANGED_CHANNEL, listener); + }, getSystemStartupSettings: (): Promise => ipcRenderer.invoke(GET_SYSTEM_STARTUP_SETTINGS_CHANNEL), setStartWithSystem: (enabled: boolean): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8e9a44d..23ff1a3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,13 +1,14 @@ -import { useEffect, useState, type ReactElement } from 'react'; -import type { UpdateState } from '../shared/update'; +import type { ReactElement } from 'react'; import { AndroidDownloadPanel } from './components/AndroidDownloadPanel'; import { PairingApprovalRequests } from './components/PairingApprovalRequests'; import { PrimaryContent } from './components/PrimaryContent'; import { StatusHeader } from './components/StatusHeader'; import { TroubleshootingDetails } from './components/TroubleshootingDetails'; +import { UpdateBanner } from './components/UpdateBanner'; import { WindowChrome } from './components/WindowTitleBar'; import { SettingsApp } from './SettingsApp'; import { useSwitchifyPcStatus } from './useSwitchifyPcStatus'; +import { useUpdateState } from './useUpdateState'; export function App(): ReactElement { if (window.location.hash === '#/settings' || window.location.hash.startsWith('#/settings/')) { @@ -20,29 +21,7 @@ export function App(): ReactElement { function MainApp(): ReactElement { const bridge = window.switchifyPc; const status = useSwitchifyPcStatus(bridge); - const [updateState, setUpdateState] = useState(null); - - useEffect(() => { - let cancelled = false; - - const refreshUpdateState = (): void => { - void bridge.getUpdateState().then((state) => { - if (!cancelled) { - setUpdateState(state); - } - }); - }; - - refreshUpdateState(); - const interval = window.setInterval(refreshUpdateState, 30_000); - window.addEventListener('focus', refreshUpdateState); - - return () => { - cancelled = true; - window.clearInterval(interval); - window.removeEventListener('focus', refreshUpdateState); - }; - }, [bridge]); + const { updateState } = useUpdateState(bridge); return ( + bridge.openSettingsWindow('updates')} /> + (null); + const { updateState, setUpdateState } = useUpdateState(bridge); const [systemStartupSettings, setSystemStartupSettings] = useState(null); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); const [isDownloadingUpdate, setIsDownloadingUpdate] = useState(false); @@ -20,10 +20,9 @@ export function SettingsApp(): ReactElement { useEffect(() => { let cancelled = false; - void Promise.all([bridge.getUpdateState(), bridge.getSystemStartupSettings()]).then( - ([updateState, systemStartupSettings]) => { + void bridge.getSystemStartupSettings().then( + (systemStartupSettings) => { if (!cancelled) { - setUpdateState(updateState); setSystemStartupSettings(systemStartupSettings); } } diff --git a/src/renderer/api.d.ts b/src/renderer/api.d.ts index b2cbc64..680a632 100644 --- a/src/renderer/api.d.ts +++ b/src/renderer/api.d.ts @@ -39,6 +39,7 @@ declare global { getUpdateState: () => Promise; checkForUpdates: () => Promise; downloadUpdate: () => Promise; + onUpdateStateChanged: (handler: (state: UpdateState) => void) => () => void; getSystemStartupSettings: () => Promise; setStartWithSystem: (enabled: boolean) => Promise; installDownloadedUpdate: () => Promise; diff --git a/src/renderer/components/UpdateBanner.tsx b/src/renderer/components/UpdateBanner.tsx new file mode 100644 index 0000000..a95e95e --- /dev/null +++ b/src/renderer/components/UpdateBanner.tsx @@ -0,0 +1,31 @@ +import type { ReactElement } from 'react'; +import type { UpdateState } from '../../shared/update'; +import { updateIndicatorState } from '../updates'; + +type UpdateBannerProps = { + updateState: UpdateState | null; + onOpenUpdates: () => Promise | void; +}; + +export function UpdateBanner({ updateState, onOpenUpdates }: UpdateBannerProps): ReactElement | null { + const indicator = updateIndicatorState(updateState); + if (indicator === 'hidden') return null; + + const isReady = indicator === 'downloaded'; + + return ( +
+
+

{isReady ? 'Update ready to install' : 'Update available'}

+

+ {isReady + ? 'The update has been downloaded and is ready to install.' + : 'A new Switchify PC update is ready to download.'} +

+
+ +
+ ); +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 6807390..aed94ca 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -384,6 +384,38 @@ a { margin-bottom: 30px; } +.update-banner { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-s); + align-items: center; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + padding: var(--space-s); + background: var(--color-surface-muted); +} + +.update-banner-title { + margin: 0; + color: var(--color-text); + font-size: 0.94rem; + font-weight: 800; +} + +.update-banner-body { + margin: 2px 0 0; + color: var(--color-text-muted); + font-size: 0.84rem; +} + +.update-banner-ready { + border-color: color-mix(in srgb, var(--color-status-ok) 45%, var(--color-border)); +} + +.update-banner-available { + border-color: color-mix(in srgb, var(--color-status-warn) 45%, var(--color-border)); +} + .setup-header-actions { display: flex; flex-wrap: wrap; @@ -1127,6 +1159,14 @@ p { white-space: normal; } + .update-banner { + grid-template-columns: 1fr; + } + + .update-banner button { + justify-self: start; + } + .android-download-panel { grid-template-columns: 1fr; justify-items: center; diff --git a/src/renderer/useUpdateState.ts b/src/renderer/useUpdateState.ts new file mode 100644 index 0000000..5dec9b7 --- /dev/null +++ b/src/renderer/useUpdateState.ts @@ -0,0 +1,31 @@ +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; +import type { UpdateState } from '../shared/update'; + +export function useUpdateState(bridge: Window['switchifyPc']): { + updateState: UpdateState | null; + setUpdateState: Dispatch>; +} { + const [updateState, setUpdateState] = useState(null); + + useEffect(() => { + let cancelled = false; + void bridge.getUpdateState().then((state) => { + if (!cancelled) { + setUpdateState(state); + } + }); + + const unsubscribe = bridge.onUpdateStateChanged((state) => { + if (!cancelled) { + setUpdateState(state); + } + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [bridge]); + + return { updateState, setUpdateState }; +} diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 0f5da96..9479441 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -21,3 +21,4 @@ export const GET_UPDATE_STATE_CHANNEL = 'updates:get-state'; export const CHECK_FOR_UPDATES_CHANNEL = 'updates:check'; export const DOWNLOAD_UPDATE_CHANNEL = 'updates:download'; export const INSTALL_DOWNLOADED_UPDATE_CHANNEL = 'updates:install-downloaded'; +export const UPDATE_STATE_CHANGED_CHANNEL = 'updates:state-changed';