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
37 changes: 25 additions & 12 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,6 +43,7 @@ let tray: SwitchifyTray | null = null;
let cursorOverlay: CursorOverlay | null = null;
let bluetoothTransport: WindowsBluetoothTransport | null = null;
let releaseHeldMouseButtons: (() => Promise<void>) | null = null;
let updateService: UpdateService | null = null;
let isQuitting = false;

if (process.platform === 'win32' && app.isPackaged) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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.');
Expand All @@ -419,6 +431,7 @@ if (!gotSingleInstanceLock) {
bluetoothTransport?.stop();
bluetoothTransport = null;
releaseHeldMouseButtons = null;
updateService = null;
controlService = null;
})
.finally(() => {
Expand Down
151 changes: 149 additions & 2 deletions src/main/updates/update-service.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 });
Expand All @@ -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 () => {
Expand Down Expand Up @@ -396,6 +540,7 @@ function createService({
quitApp = vi.fn(),
diagnosticsFilePath = null,
removeFile = vi.fn(async () => undefined),
onStateChanged = vi.fn(),
isPackaged = true,
platform = 'win32'
}: {
Expand All @@ -404,6 +549,7 @@ function createService({
quitApp?: () => void;
diagnosticsFilePath?: string | null;
removeFile?: (path: string) => Promise<void>;
onStateChanged?: (state: import('../../shared/update').UpdateState) => void;
isPackaged?: boolean;
platform?: NodeJS.Platform;
} = {}): UpdateService {
Expand All @@ -417,6 +563,7 @@ function createService({
quitApp,
diagnosticsFilePath,
removeFile,
onStateChanged,
now: () => new Date('2026-06-12T12:00:00.000Z')
});
}
Expand Down
Loading