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
4 changes: 3 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,9 @@ if (!gotSingleInstanceLock) {
currentVersion: app.getVersion(),
isPackaged: app.isPackaged,
platform: process.platform,
autoUpdater
resourcesPath: process.resourcesPath,
autoUpdater,
quitApp
})
);
void bluetoothTransport.start();
Expand Down
169 changes: 169 additions & 0 deletions src/main/updates/update-installer-launcher.test.ts
Original file line number Diff line number Diff line change
@@ -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<SpawnInstaller>(() => 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<ReturnType<typeof launchWindowsUpdateInstaller> extends Promise<infer Result> ? Result : never> {
return launchWindowsUpdateInstaller({
installerPath,
resourcesPath,
settleMs,
spawnInstaller,
fileExists
});
}
95 changes: 95 additions & 0 deletions src/main/updates/update-installer-launcher.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateInstallerLaunchResult> {
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' });
});
});
}
19 changes: 17 additions & 2 deletions src/main/updates/update-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<UpdateInstallConfirmation>(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<UpdateInstallConfirmation>(async () => false);
Expand Down Expand Up @@ -91,7 +106,7 @@ function createUpdateService({
installResult
}: {
downloaded: boolean;
installResult: { ok: boolean; reason?: string };
installResult: UpdateInstallResult;
}): UpdateService {
return {
getState: vi.fn(() => ({
Expand All @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions src/main/updates/update-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
Loading