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
81 changes: 81 additions & 0 deletions apps/desktop/src/main/__tests__/updater.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Listener[]>(),
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();
});
});
4 changes: 3 additions & 1 deletion apps/desktop/src/main/system-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -45,6 +45,8 @@ export function systemContextFor(win: BrowserWindow): SystemContext {
app: {
getVersion: () => app.getVersion(),
checkForUpdates: () => checkForUpdates(),
getUpdaterState: () => getUpdaterState(),
installUpdate: () => installUpdate(),
},
settings: {
get: () => getSettings(),
Expand Down
72 changes: 40 additions & 32 deletions apps/desktop/src/main/updater.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,17 +15,23 @@ const updatesDisabled = (): boolean => CHANNEL === 'development';
* packaged app.
*/

type UpdaterStatusListener = (status: UpdaterStatus) => void;
const statusListeners = new Set<UpdaterStatusListener>();
type UpdaterStateListener = (state: UpdaterState) => void;
const stateListeners = new Set<UpdaterStateListener>();
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 {
Expand All @@ -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<void> {
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();
}
8 changes: 4 additions & 4 deletions apps/desktop/src/main/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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);

Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/renderer/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
},
Expand Down
6 changes: 2 additions & 4 deletions apps/desktop/src/renderer/src/settings/about-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<UpdaterStatus>('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];
Expand All @@ -43,7 +42,6 @@ export function AboutTab(): React.ReactNode {
size="sm"
variant="outline"
onClick={() => {
setStatus('checking');
void systemBridge.app.checkForUpdates();
}}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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(<UpdateNotice />);
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();
});
});
Loading