diff --git a/apps/desktop/src/main/__tests__/updater.test.ts b/apps/desktop/src/main/__tests__/updater.test.ts new file mode 100644 index 000000000..98cdce29b --- /dev/null +++ b/apps/desktop/src/main/__tests__/updater.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type Listener = (...args: unknown[]) => void; + +const mocks = vi.hoisted(() => ({ + listeners: new Map(), + autoUpdater: { + logger: null, + on: vi.fn(), + checkForUpdates: vi.fn(() => Promise.resolve(null)), + quitAndInstall: vi.fn(), + }, + log: { + transports: { file: { level: 'info' } }, + error: vi.fn(), + }, +})); + +vi.mock('electron-log', () => ({ default: mocks.log })); +vi.mock('electron-updater', () => ({ autoUpdater: mocks.autoUpdater })); +vi.mock('../constants', () => ({ CHANNEL: 'release' })); + +function emit(event: string, ...args: unknown[]): void { + for (const listener of mocks.listeners.get(event) ?? []) listener(...args); +} + +describe('desktop updater', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useFakeTimers(); + mocks.listeners.clear(); + mocks.autoUpdater.on.mockImplementation((event: string, listener: Listener) => { + const listeners = mocks.listeners.get(event) ?? []; + listeners.push(listener); + mocks.listeners.set(event, listeners); + return mocks.autoUpdater; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('settles checks when the updater is inactive for the current package format', async () => { + const updater = await import('../updater'); + + updater.checkForUpdates(); + expect(updater.getUpdaterState()).toEqual({ status: 'checking', version: null }); + + await mocks.autoUpdater.checkForUpdates.mock.results[0]?.value; + expect(updater.getUpdaterState()).toEqual({ status: 'not-available', version: null }); + }); + + it('polls every four hours and preserves a downloaded update until installation', async () => { + const updater = await import('../updater'); + const states: unknown[] = []; + updater.onUpdaterState((state) => states.push(state)); + + updater.initAutoUpdates(); + expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1); + expect(() => updater.installUpdate()).toThrow('No downloaded update to install'); + + await vi.advanceTimersByTimeAsync(4 * 60 * 60 * 1000); + expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(2); + + emit('update-available', { version: '0.13.0' }); + emit('download-progress', { percent: 50 }); + emit('update-downloaded', { version: '0.13.0' }); + + expect(updater.getUpdaterState()).toEqual({ status: 'downloaded', version: '0.13.0' }); + expect(states).toContainEqual({ status: 'available', version: '0.13.0' }); + expect(states).toContainEqual({ status: 'downloading', version: '0.13.0' }); + + await vi.advanceTimersByTimeAsync(4 * 60 * 60 * 1000); + expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(2); + + updater.installUpdate(); + expect(mocks.autoUpdater.quitAndInstall).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/main/system-context.ts b/apps/desktop/src/main/system-context.ts index b8ea9e70d..db489ec7d 100644 --- a/apps/desktop/src/main/system-context.ts +++ b/apps/desktop/src/main/system-context.ts @@ -8,7 +8,7 @@ import { isDaemonManaged, retryDaemonSupervisor } from './daemon-supervisor'; import { ensureDefaultPickerDirectory } from './default-picker-directory'; import { listEditors, openInEditor } from './editors'; import { getSettings, setSettings } from './settings'; -import { checkForUpdates } from './updater'; +import { checkForUpdates, getUpdaterState, installUpdate } from './updater'; /** Binds the system IPC capability contract to the real Electron implementation (system / UI only, docs/ARCHITECTURE.md#core-principles). */ export function systemContextFor(win: BrowserWindow): SystemContext { @@ -45,6 +45,8 @@ export function systemContextFor(win: BrowserWindow): SystemContext { app: { getVersion: () => app.getVersion(), checkForUpdates: () => checkForUpdates(), + getUpdaterState: () => getUpdaterState(), + installUpdate: () => installUpdate(), }, settings: { get: () => getSettings(), diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 5d718351a..540a0756e 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,9 +1,11 @@ -import type { UpdaterStatus } from '@linkcode/ipc'; -import { dialog } from 'electron'; +import type { UpdaterState, UpdaterStatus } from '@linkcode/ipc'; import log from 'electron-log'; import { autoUpdater } from 'electron-updater'; +import { noop } from 'foxts/noop'; import { CHANNEL } from './constants'; +const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; + /** A dev shell must never pull the production feed and replace itself with the release build. */ const updatesDisabled = (): boolean => CHANNEL === 'development'; @@ -13,17 +15,23 @@ const updatesDisabled = (): boolean => CHANNEL === 'development'; * packaged app. */ -type UpdaterStatusListener = (status: UpdaterStatus) => void; -const statusListeners = new Set(); +type UpdaterStateListener = (state: UpdaterState) => void; +const stateListeners = new Set(); +let updaterState: UpdaterState = { status: 'idle', version: null }; + +/** Subscribe to auto-update lifecycle state; the IPC layer forwards these to the renderer. */ +export function onUpdaterState(listener: UpdaterStateListener): () => void { + stateListeners.add(listener); + return () => stateListeners.delete(listener); +} -/** Subscribe to auto-update lifecycle status; the IPC layer forwards these to the renderer. */ -export function onUpdaterStatus(listener: UpdaterStatusListener): () => void { - statusListeners.add(listener); - return () => statusListeners.delete(listener); +export function getUpdaterState(): UpdaterState { + return updaterState; } -function emitStatus(status: UpdaterStatus): void { - for (const listener of statusListeners) listener(status); +function emitState(status: UpdaterStatus, version = updaterState.version): void { + updaterState = { status, version }; + for (const listener of stateListeners) listener(updaterState); } export function initAutoUpdates(): void { @@ -32,43 +40,43 @@ export function initAutoUpdates(): void { autoUpdater.logger = log; log.transports.file.level = 'info'; - autoUpdater.on('checking-for-update', () => emitStatus('checking')); - autoUpdater.on('update-available', () => emitStatus('available')); - autoUpdater.on('update-not-available', () => emitStatus('not-available')); - autoUpdater.on('download-progress', () => emitStatus('downloading')); + autoUpdater.on('checking-for-update', () => emitState('checking', null)); + autoUpdater.on('update-available', ({ version }) => emitState('available', version)); + autoUpdater.on('update-not-available', () => emitState('not-available', null)); + autoUpdater.on('download-progress', () => emitState('downloading')); autoUpdater.on('update-downloaded', ({ version }) => { - emitStatus('downloaded'); - void promptInstall(version); + emitState('downloaded', version); }); autoUpdater.on('error', (err) => { - emitStatus('error'); + emitState('error'); log.error('[link-code/desktop] auto-update failed:', err); }); // autoDownload defaults to true, so a found update downloads and fires `update-downloaded`. - void autoUpdater.checkForUpdates(); + checkForUpdates(); + const timer = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL_MS); + timer.unref(); } /** Manual update check triggered from Settings → About. */ export function checkForUpdates(): void { if (updatesDisabled()) { // Dev shells have no feed of their own; report a stable result. - emitStatus('not-available'); + emitState('not-available', null); return; } - emitStatus('checking'); - void autoUpdater.checkForUpdates().catch(() => emitStatus('error')); + if (updaterState.status === 'downloaded') return; + emitState('checking', null); + // electron-updater emits `error` before rejecting; that listener owns status and logging. + void autoUpdater + .checkForUpdates() + .then((result) => { + if (result === null) emitState('not-available', null); + }) + .catch(noop); } -async function promptInstall(version: string): Promise { - const { response } = await dialog.showMessageBox({ - type: 'info', - buttons: ['Restart now', 'Later'], - defaultId: 0, - cancelId: 1, - title: 'Update ready', - message: `LinkCode ${version} has been downloaded.`, - detail: 'Restart to finish installing the update.', - }); - if (response === 0) autoUpdater.quitAndInstall(); +export function installUpdate(): void { + if (updaterState.status !== 'downloaded') throw new Error('No downloaded update to install'); + autoUpdater.quitAndInstall(); } diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 539f207b8..8062e3d05 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -7,7 +7,7 @@ import { BROWSER_OPEN_TAB_CHANNEL, BROWSER_SHORTCUT_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, - UPDATER_STATUS_CHANNEL, + UPDATER_STATE_CHANNEL, } from '@linkcode/ipc'; import { bindElectronSystemIpc } from '@linkcode/ipc/electron-main'; import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'; @@ -20,7 +20,7 @@ import { desktopBackdropOptions, desktopBackgroundColor } from './appearance'; import { APP_NAME } from './constants'; import { watchDaemonRuntime } from './daemon-discovery'; import { systemContextFor } from './system-context'; -import { onUpdaterStatus } from './updater'; +import { onUpdaterState } from './updater'; import { deriveDefaultWindowSize, MIN_WINDOW_SIZE, @@ -39,8 +39,8 @@ export function createDesktopWindow(): BrowserWindow { bindElectronSystemIpc({ ipcMain, window: win, ctx }); // Forward auto-update status (a main-side singleton) to this window's renderer. - const unsubscribeUpdater = onUpdaterStatus((status) => { - if (!win.isDestroyed()) win.webContents.send(UPDATER_STATUS_CHANNEL, status); + const unsubscribeUpdater = onUpdaterState((state) => { + if (!win.isDestroyed()) win.webContents.send(UPDATER_STATE_CHANNEL, state); }); win.once('closed', unsubscribeUpdater); diff --git a/apps/desktop/src/renderer/src/ipc.ts b/apps/desktop/src/renderer/src/ipc.ts index 6e452c6ce..cc100eb63 100644 --- a/apps/desktop/src/renderer/src/ipc.ts +++ b/apps/desktop/src/renderer/src/ipc.ts @@ -41,8 +41,10 @@ export const systemBridge: SystemBridge = { platform: source.app.platform, checkForUpdates: () => traceRendererIpc('app.check-for-updates', () => source.app.checkForUpdates()), - onUpdaterStatus: (callback) => - traceRendererIpc('app.on-updater-status', () => source.app.onUpdaterStatus(callback)), + updaterState: () => traceRendererIpc('app.updater-state', () => source.app.updaterState()), + installUpdate: () => traceRendererIpc('app.install-update', () => source.app.installUpdate()), + onUpdaterState: (callback) => + traceRendererIpc('app.on-updater-state', () => source.app.onUpdaterState(callback)), onOpenSettings: (callback) => traceRendererIpc('app.on-open-settings', () => source.app.onOpenSettings(callback)), }, diff --git a/apps/desktop/src/renderer/src/settings/about-tab.tsx b/apps/desktop/src/renderer/src/settings/about-tab.tsx index f7d198365..9964251db 100644 --- a/apps/desktop/src/renderer/src/settings/about-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/about-tab.tsx @@ -5,6 +5,7 @@ import { useEffect } from 'foxact/use-abortable-effect'; import { useState } from 'react'; import { useTranslations } from 'use-intl'; import { systemBridge } from '../ipc'; +import { useUpdaterState } from '../updater'; const STATUS_KEYS = { checking: 'status.checking', @@ -18,14 +19,12 @@ const STATUS_KEYS = { export function AboutTab(): React.ReactNode { const t = useTranslations('settings.about'); const [version, setVersion] = useState(''); - const [status, setStatus] = useState('idle'); + const { status } = useUpdaterState(); useEffect((signal) => { void systemBridge.app.version().then((value) => { if (!signal.aborted) setVersion(value); }); - const unsubscribe = systemBridge.app.onUpdaterStatus(setStatus); - return () => unsubscribe(); }, []); const statusKey = status === 'idle' ? null : STATUS_KEYS[status]; @@ -43,7 +42,6 @@ export function AboutTab(): React.ReactNode { size="sm" variant="outline" onClick={() => { - setStatus('checking'); void systemBridge.app.checkForUpdates(); }} > diff --git a/apps/desktop/src/renderer/src/shell/__tests__/update-notice.test.tsx b/apps/desktop/src/renderer/src/shell/__tests__/update-notice.test.tsx new file mode 100644 index 000000000..102302ccb --- /dev/null +++ b/apps/desktop/src/renderer/src/shell/__tests__/update-notice.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom + +import type { UpdaterState } from '@linkcode/ipc'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { asyncNoop } from 'foxts/noop'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { UpdateNotice } from '../update-notice'; + +const mocks = vi.hoisted(() => ({ + stateListener: undefined as ((state: UpdaterState) => void) | undefined, + updaterState: vi.fn(() => + Promise.resolve({ status: 'idle', version: null } satisfies UpdaterState), + ), + installUpdate: vi.fn(), + unsubscribe: vi.fn(), + onUpdaterState: vi.fn(), +})); + +vi.mock('../../ipc', () => ({ + systemBridge: { + app: { + updaterState: mocks.updaterState, + installUpdate: mocks.installUpdate, + onUpdaterState: mocks.onUpdaterState, + }, + }, +})); + +vi.mock('use-intl', () => ({ + useTranslations() { + const messages: Record = { + updateReady: 'Update ready', + updateNow: 'Restart to update', + changelog: 'Changelog', + }; + return (key: string) => messages[key] ?? key; + }, +})); + +describe('UpdateNotice', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.stateListener = undefined; + mocks.onUpdaterState.mockImplementation((listener: (state: UpdaterState) => void) => { + mocks.stateListener = listener; + return mocks.unsubscribe; + }); + }); + + afterEach(cleanup); + + it('offers installation and the matching release notes once the download is ready', async () => { + const { unmount } = render(); + await act(asyncNoop); + expect(screen.queryByText('Update ready')).toBeNull(); + + act(() => { + mocks.stateListener?.({ status: 'downloaded', version: '0.13.0' }); + }); + + expect(screen.getByText('v0.13.0')).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Changelog' }).getAttribute('href')).toBe( + 'https://github.com/arcboxlabs/linkcode/releases/tag/v0.13.0', + ); + + fireEvent.click(screen.getByRole('button', { name: 'Restart to update' })); + expect(mocks.installUpdate).toHaveBeenCalledOnce(); + + unmount(); + expect(mocks.unsubscribe).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 3eabba8e1..36359d26f 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -59,6 +59,7 @@ import type { WorkspaceSide } from './layout/workspace'; import { DesktopWorkspace } from './layout/workspace'; import { getExpandedPanel } from './store/model'; import { useDesktopShellStore } from './store/store'; +import { UpdateNotice } from './update-notice'; import { useDesktopPaletteCommands } from './use-desktop-palette-commands'; import { useDesktopShellShortcuts } from './use-desktop-shell-shortcuts'; @@ -764,21 +765,24 @@ export function DesktopShell({ collapsedSections={collapsedSections} topInsetClassName={DESKTOP_CHROME_SPACER_CLASS} footer={ - + <> + + + } onPickDirectory={pickDirectory} onOpenSearch={onOpenSearch} diff --git a/apps/desktop/src/renderer/src/shell/update-notice.tsx b/apps/desktop/src/renderer/src/shell/update-notice.tsx new file mode 100644 index 000000000..684c7e93f --- /dev/null +++ b/apps/desktop/src/renderer/src/shell/update-notice.tsx @@ -0,0 +1,48 @@ +import { Button } from 'coss-ui/components/button'; +import { SidebarFooter } from 'coss-ui/components/sidebar'; +import { CircleArrowUpIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import { systemBridge } from '../ipc'; +import { useUpdaterState } from '../updater'; + +export function UpdateNotice(): React.ReactNode { + const t = useTranslations('workbench.sidebar'); + const { status, version } = useUpdaterState(); + + if (status !== 'downloaded' || !version) return null; + + const changelogUrl = `https://github.com/arcboxlabs/linkcode/releases/tag/${encodeURIComponent(`v${version}`)}`; + + return ( + +
+
+ +
+
{t('updateReady')}
+
v{version}
+
+
+
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/updater.ts b/apps/desktop/src/renderer/src/updater.ts new file mode 100644 index 000000000..797824018 --- /dev/null +++ b/apps/desktop/src/renderer/src/updater.ts @@ -0,0 +1,43 @@ +import type { UpdaterState } from '@linkcode/ipc'; +import { useSyncExternalStore } from 'react'; +import { systemBridge } from './ipc'; + +let snapshot: UpdaterState = { status: 'idle', version: null }; +const listeners = new Set<() => void>(); +let unsubscribe: (() => void) | undefined; + +function publish(next: UpdaterState): void { + snapshot = next; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + if (!unsubscribe) { + unsubscribe = systemBridge.app.onUpdaterState(publish); + const pendingSnapshot = snapshot; + void systemBridge.app + .updaterState() + .then((state) => { + if (snapshot === pendingSnapshot) publish(state); + }) + .catch(() => { + if (snapshot === pendingSnapshot) publish({ status: 'error', version: null }); + }); + } + + return () => { + listeners.delete(listener); + if (listeners.size > 0) return; + unsubscribe?.(); + unsubscribe = undefined; + }; +} + +export function useUpdaterState(): UpdaterState { + return useSyncExternalStore( + subscribe, + () => snapshot, + () => snapshot, + ); +} diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 668d8c0cc..7692cccfe 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -44,6 +44,7 @@ All signing and R2 secrets live in the repo's GitHub **`release` Environment** ( ## Update feed & artifact naming - The auto-update feed is an electron-updater **`generic` provider at `https://releases.linkcode.ai/desktop`** (Cloudflare R2 bucket `linkcode-releases`), hardcoded in `electron-builder.yml` `publish:` and baked into every shipped app — **never change it** (changing it strands installed clients). The app carries the URL only in packaging config, never in code (`apps/desktop/src/main/updater.ts` reads it from the baked publish block); auto-update is off in the dev shell. The `/desktop` prefix namespaces the bucket for future artifact families. +- Release builds check at startup and every four hours. electron-updater downloads automatically; once ready, the lower-left sidebar offers an immediate restart or the matching GitHub Changelog. Ignoring the prompt keeps its default install-on-quit behavior. - `artifactName: ${productName}-${version}-${arch}.${ext}` — the `${arch}` suffix is **load-bearing**: electron-updater picks the feed entry whose filename contains `process.arch` and silently falls back to the *first* entry otherwise, handing some clients the wrong arch. All targets build **per-arch `[x64, arm64]`**, never universal. mac ships `dmg` + `zip` (**the `zip` is required for auto-update** — the updater pulls the zip, not the dmg); win ships `nsis` (`oneClick: false`, `perMachine: false`, `buildUniversalInstaller: false`); linux ships `AppImage` (the only self-updating Linux format) + `deb`, `executableName: linkcode`. rpm/snap deliberately off. - **R2 publish** (`release-desktop.yml`): `aws s3 sync artifacts/ s3://linkcode-releases/desktop/ --endpoint-url https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com` with **no `--delete`** (prior versions stay for delta updates). Mandatory env: `AWS_REGION=auto` (R2 ignores it but the CLI requires one), `AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED` + `AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED` (R2 doesn't implement the CRC32 upload checksums recent aws-cli sends). Creds: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ACCOUNT_ID`. diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index a37e6b7f1..d48e53f0b 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -547,6 +547,9 @@ export const en = { remoteSignedOut: 'Sign in to connect', remoteHostsLoading: 'Checking online hosts…', remoteHostsEmpty: 'No hosts online', + updateReady: 'Update ready', + updateNow: 'Restart to update', + changelog: 'Changelog', newThread: 'New thread', groupActions: 'Workspace actions', expandGroup: 'Expand', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index feb96c84b..a10ad107a 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -536,6 +536,9 @@ export const zhCN = { remoteSignedOut: '登录后可连接远程主机', remoteHostsLoading: '正在检查在线主机…', remoteHostsEmpty: '没有在线主机', + updateReady: '更新已就绪', + updateNow: '重启更新', + changelog: '更新日志', newThread: '新建线程', groupActions: '工作区操作', expandGroup: '展开', diff --git a/packages/system-plane/ipc/src/bridge.ts b/packages/system-plane/ipc/src/bridge.ts index c462b2484..e4a1cd849 100644 --- a/packages/system-plane/ipc/src/bridge.ts +++ b/packages/system-plane/ipc/src/bridge.ts @@ -6,7 +6,7 @@ import type { DetectedEditor, PickFileOptions, SystemNotification, - UpdaterStatus, + UpdaterState, } from './context'; /** The capability contract of TypeSafe IPC (docs/ARCHITECTURE.md#key-contracts) — system / UI @@ -38,10 +38,12 @@ export interface SystemBridge { version(): Promise; /** Synchronous Electron platform supplied by the sandboxed preload. */ readonly platform: NodeJS.Platform; - /** Trigger a manual update check; observe progress via `onUpdaterStatus`. */ + /** Trigger a manual update check; observe progress via `onUpdaterState`. */ checkForUpdates(): Promise; - /** Subscribe to auto-update lifecycle status pushed from main. */ - onUpdaterStatus(cb: (status: UpdaterStatus) => void): () => void; + updaterState(): Promise; + installUpdate(): Promise; + /** Subscribe to auto-update lifecycle state pushed from main. */ + onUpdaterState(cb: (state: UpdaterState) => void): () => void; /** Subscribe to the menubar/Cmd+, "open settings" push from main. */ onOpenSettings(cb: () => void): () => void; }; diff --git a/packages/system-plane/ipc/src/context.ts b/packages/system-plane/ipc/src/context.ts index a9e745474..d41728c97 100644 --- a/packages/system-plane/ipc/src/context.ts +++ b/packages/system-plane/ipc/src/context.ts @@ -29,6 +29,8 @@ export interface SystemContext { getVersion(): string; /** Trigger a manual update check (no-op when the app is not packaged). */ checkForUpdates(): void; + getUpdaterState(): UpdaterState; + installUpdate(): void; }; settings: { get(): DesktopSettings; @@ -130,6 +132,12 @@ export const UpdaterStatusSchema = z.enum([ ]); export type UpdaterStatus = z.infer; +export const UpdaterStateSchema = z.object({ + status: UpdaterStatusSchema, + version: z.string().nullable(), +}); +export type UpdaterState = z.infer; + /** Terminal state of a Browser-pane download, pushed main → renderer for a toast. */ export interface BrowserDownloadDone { filename: string; diff --git a/packages/system-plane/ipc/src/electron-main.ts b/packages/system-plane/ipc/src/electron-main.ts index 6d8a6ed0b..85ee654cc 100644 --- a/packages/system-plane/ipc/src/electron-main.ts +++ b/packages/system-plane/ipc/src/electron-main.ts @@ -8,6 +8,7 @@ import { PickFileOptionsSchema, ShellPathSchema, SystemNotificationSchema, + UpdaterStateSchema, } from './context'; import { DAEMON_URL_SNAPSHOT_CHANNEL, @@ -49,6 +50,8 @@ export function bindElectronSystemIpc({ ctx.shell.openInEditor(OpenInEditorRequestSchema.parse(request)), appVersion: () => ctx.app.getVersion(), appCheckForUpdates: () => ctx.app.checkForUpdates(), + appUpdaterState: () => UpdaterStateSchema.parse(ctx.app.getUpdaterState()), + appInstallUpdate: () => ctx.app.installUpdate(), daemonIsManaged: () => ctx.daemon.isManaged(), daemonRetry: () => ctx.daemon.retry(), settingsGet: () => ctx.settings.get(), diff --git a/packages/system-plane/ipc/src/electron-renderer.ts b/packages/system-plane/ipc/src/electron-renderer.ts index 803350af0..d68f0fe69 100644 --- a/packages/system-plane/ipc/src/electron-renderer.ts +++ b/packages/system-plane/ipc/src/electron-renderer.ts @@ -3,7 +3,7 @@ import { defineInvokes } from '@moeru/eventa'; import { createContext as createRendererContext } from '@moeru/eventa/adapters/electron/renderer'; import type { IpcRenderer } from 'electron'; import type { SystemBridge } from './bridge'; -import type { BrowserDownloadDone, DesktopSettings, UpdaterStatus } from './context'; +import type { BrowserDownloadDone, DesktopSettings, UpdaterState } from './context'; import { BROWSER_DOWNLOAD_DONE_CHANNEL, BROWSER_OPEN_TAB_CHANNEL, @@ -14,7 +14,7 @@ import { SETTINGS_OPEN_CHANNEL, SETTINGS_SNAPSHOT_CHANNEL, systemIpcEvents, - UPDATER_STATUS_CHANNEL, + UPDATER_STATE_CHANNEL, WINDOW_MAXIMIZED_CHANGED_CHANNEL, } from './events'; @@ -63,12 +63,21 @@ export function createElectronSystemBridge( version: () => invoke.appVersion(), platform, checkForUpdates: () => invoke.appCheckForUpdates(), - onUpdaterStatus(cb) { + updaterState: () => invoke.appUpdaterState(), + installUpdate: () => invoke.appInstallUpdate(), + onUpdaterState(cb) { const handler: IpcRendererListener = (_event, value: unknown) => { - if (typeof value === 'string') cb(value as UpdaterStatus); + if ( + typeof value === 'object' && + value !== null && + 'status' in value && + 'version' in value + ) { + cb(value as UpdaterState); + } }; - ipcRenderer.on(UPDATER_STATUS_CHANNEL, handler); - return () => ipcRenderer.removeListener(UPDATER_STATUS_CHANNEL, handler); + ipcRenderer.on(UPDATER_STATE_CHANNEL, handler); + return () => ipcRenderer.removeListener(UPDATER_STATE_CHANNEL, handler); }, onOpenSettings(cb) { const handler: IpcRendererListener = () => cb(); diff --git a/packages/system-plane/ipc/src/events.ts b/packages/system-plane/ipc/src/events.ts index 57f087ebc..321f735c7 100644 --- a/packages/system-plane/ipc/src/events.ts +++ b/packages/system-plane/ipc/src/events.ts @@ -6,6 +6,7 @@ import type { OpenInEditorRequest, PickFileOptions, SystemNotification, + UpdaterState, } from './context'; export const WINDOW_MAXIMIZED_CHANGED_CHANNEL = 'linkcode.system.window.maximizedChanged'; @@ -15,8 +16,8 @@ export const SETTINGS_SNAPSHOT_CHANNEL = 'linkcode.system.settings.snapshot'; export const DAEMON_URL_SNAPSHOT_CHANNEL = 'linkcode.system.daemon.urlSnapshot'; /** Main → renderer push: the menubar/Cmd+, asked to open Settings. */ export const SETTINGS_OPEN_CHANNEL = 'linkcode.system.settings.open'; -/** Main → renderer push: auto-update lifecycle status. */ -export const UPDATER_STATUS_CHANNEL = 'linkcode.system.app.updaterStatus'; +/** Main → renderer push: auto-update lifecycle state. */ +export const UPDATER_STATE_CHANNEL = 'linkcode.system.app.updaterState'; /** Main → renderer push: the daemon runtime file changed — rediscover the endpoint. */ export const DAEMON_RUNTIME_CHANGED_CHANNEL = 'linkcode.system.daemon.runtimeChanged'; /** Main → renderer push: an OS notification was clicked; payload is its `clickToken`. */ @@ -43,6 +44,8 @@ export const systemIpcEvents = { ), appVersion: defineInvokeEventa('linkcode.system.app.version'), appCheckForUpdates: defineInvokeEventa('linkcode.system.app.checkForUpdates'), + appUpdaterState: defineInvokeEventa('linkcode.system.app.updaterState'), + appInstallUpdate: defineInvokeEventa('linkcode.system.app.installUpdate'), daemonIsManaged: defineInvokeEventa('linkcode.system.daemon.isManaged'), daemonRetry: defineInvokeEventa('linkcode.system.daemon.retry'), settingsGet: defineInvokeEventa('linkcode.system.settings.get'),