From 58af52e631ae8896e60d19dff508f67ace73e337 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 24 Jun 2026 12:11:51 +0100 Subject: [PATCH 1/2] Fix Windows startup registration --- src/main/index.ts | 29 +++- src/main/startup-diagnostics.test.ts | 81 ++++++++++ src/main/startup-diagnostics.ts | 50 ++++++ src/main/system-startup.test.ts | 186 +++++++++++++++------- src/main/system-startup.ts | 73 ++++----- src/main/windows-startup-registry.test.ts | 152 ++++++++++++++++++ src/main/windows-startup-registry.ts | 138 ++++++++++++++++ src/renderer/components/SettingsPanel.tsx | 22 +++ src/shared/system-startup.ts | 6 + 9 files changed, 640 insertions(+), 97 deletions(-) create mode 100644 src/main/startup-diagnostics.test.ts create mode 100644 src/main/startup-diagnostics.ts create mode 100644 src/main/windows-startup-registry.test.ts create mode 100644 src/main/windows-startup-registry.ts diff --git a/src/main/index.ts b/src/main/index.ts index 33a4887..8101836 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -25,12 +25,14 @@ import { JsonPointerMovementSettingsStore } from './pointer-movement-settings-st import { registerServerIpc } from './server-ipc'; import { registerSettingsWindowIpc } from './settings-window-ipc'; import { secondInstanceAction } from './single-instance'; +import { appendStartupDiagnostics } from './startup-diagnostics'; import { registerSystemStartupIpc } from './system-startup-ipc'; import { shouldStartHidden, SystemStartupService } from './system-startup'; import { createSwitchifyTray, type SwitchifyTray } from './tray'; import { registerUpdateIpc } from './updates/update-ipc'; import { UpdateService } from './updates/update-service'; import { WINDOWS_APP_USER_MODEL_ID } from './windows-app-user-model-id'; +import { createWindowsStartupRegistry } from './windows-startup-registry'; const isDev = Boolean(process.env.ELECTRON_RENDERER_URL); let controlService: ControlService | null = null; @@ -259,10 +261,31 @@ if (!gotSingleInstanceLock) { platform: process.platform, isPackaged: app.isPackaged, executablePath: process.execPath, - appUserModelId: WINDOWS_APP_USER_MODEL_ID, - getLoginItemSettings: (options) => app.getLoginItemSettings(options), - setLoginItemSettings: (settings) => app.setLoginItemSettings(settings) + startupRegistry: createWindowsStartupRegistry() }); + void systemStartup + .getSettings() + .then((settings) => { + appendStartupDiagnostics(join(app.getPath('userData'), 'startup-diagnostics.jsonl'), { + startedAt: new Date().toISOString(), + version: app.getVersion(), + isPackaged: app.isPackaged, + platform: process.platform, + executablePath: process.execPath, + argv: process.argv, + startHidden, + startupRegistration: settings.registration + ? { + startWithSystem: settings.startWithSystem, + registeredCommand: settings.registration.registeredCommand, + startupApproved: settings.registration.startupApproved + } + : undefined + }); + }) + .catch((error) => { + console.warn(error instanceof Error ? error.message : 'Could not write startup diagnostics.'); + }); const pairingStore = new JsonPairingStore(join(app.getPath('userData'), 'pairing-state.json')); const cursorOverlaySettingsStore = new JsonCursorOverlaySettingsStore( join(app.getPath('userData'), 'cursor-overlay-settings.json') diff --git a/src/main/startup-diagnostics.test.ts b/src/main/startup-diagnostics.test.ts new file mode 100644 index 0000000..e1d6ddf --- /dev/null +++ b/src/main/startup-diagnostics.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { appendStartupDiagnostics, type StartupDiagnosticsEntry } from './startup-diagnostics'; + +describe('appendStartupDiagnostics', () => { + it('appends a JSONL diagnostics entry', () => { + const filePath = diagnosticsPath(); + + appendStartupDiagnostics(filePath, entry({ startedAt: '2026-06-24T10:00:00.000Z' })); + + const lines = readLines(filePath); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toMatchObject({ + startedAt: '2026-06-24T10:00:00.000Z', + version: '0.1.15', + startHidden: true + }); + }); + + it('keeps only the newest 50 lines', () => { + const filePath = diagnosticsPath(); + + for (let index = 0; index < 55; index += 1) { + appendStartupDiagnostics(filePath, entry({ startedAt: `2026-06-24T10:${String(index).padStart(2, '0')}:00.000Z` })); + } + + const lines = readLines(filePath); + expect(lines).toHaveLength(50); + expect(JSON.parse(lines[0]).startedAt).toBe('2026-06-24T10:05:00.000Z'); + expect(JSON.parse(lines[49]).startedAt).toBe('2026-06-24T10:54:00.000Z'); + }); + + it('creates the parent directory if it is missing', () => { + const filePath = join(mkdtempSync(join(tmpdir(), 'switchify-startup-')), 'nested', 'startup.jsonl'); + + appendStartupDiagnostics(filePath, entry()); + + expect(readLines(filePath)).toHaveLength(1); + }); + + it('drops malformed existing lines without throwing', () => { + const filePath = diagnosticsPath(); + writeFileSync(filePath, 'not json\n{"startedAt":"old"}\n', 'utf8'); + + appendStartupDiagnostics(filePath, entry()); + + const lines = readLines(filePath); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0])).toEqual({ startedAt: 'old' }); + }); +}); + +function diagnosticsPath(): string { + return join(mkdtempSync(join(tmpdir(), 'switchify-startup-')), 'startup-diagnostics.jsonl'); +} + +function readLines(filePath: string): string[] { + return readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .filter(Boolean); +} + +function entry(overrides: Partial = {}): StartupDiagnosticsEntry { + return { + startedAt: '2026-06-24T10:00:00.000Z', + version: '0.1.15', + isPackaged: true, + platform: 'win32', + executablePath: 'C:\\Program Files\\Switchify PC\\Switchify PC.exe', + argv: ['Switchify PC.exe', '--start-hidden'], + startHidden: true, + startupRegistration: { + startWithSystem: true, + registeredCommand: '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden', + startupApproved: 'enabled' + }, + ...overrides + }; +} diff --git a/src/main/startup-diagnostics.ts b/src/main/startup-diagnostics.ts new file mode 100644 index 0000000..84c6d6b --- /dev/null +++ b/src/main/startup-diagnostics.ts @@ -0,0 +1,50 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { StartupApprovedState } from '../shared/system-startup'; + +export type StartupDiagnosticsEntry = { + startedAt: string; + version: string; + isPackaged: boolean; + platform: NodeJS.Platform; + executablePath: string; + argv: string[]; + startHidden: boolean; + startupRegistration?: { + startWithSystem: boolean; + registeredCommand: string | null; + startupApproved: StartupApprovedState; + }; +}; + +const MAX_STARTUP_DIAGNOSTICS_LINES = 50; + +export function appendStartupDiagnostics(filePath: string, entry: StartupDiagnosticsEntry): void { + try { + mkdirSync(dirname(filePath), { recursive: true }); + const existingLines = readExistingLines(filePath); + const nextLines = [...existingLines, JSON.stringify(entry)].slice(-MAX_STARTUP_DIAGNOSTICS_LINES); + writeFileSync(filePath, `${nextLines.join('\n')}\n`, 'utf8'); + } catch (error) { + console.warn(error instanceof Error ? error.message : 'Could not write startup diagnostics.'); + } +} + +function readExistingLines(filePath: string): string[] { + try { + return readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => { + try { + JSON.parse(line); + return true; + } catch { + return false; + } + }); + } catch { + return []; + } +} diff --git a/src/main/system-startup.test.ts b/src/main/system-startup.test.ts index 7e948df..bf52510 100644 --- a/src/main/system-startup.test.ts +++ b/src/main/system-startup.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import { START_HIDDEN_ARG, shouldStartHidden, SystemStartupService } from './system-startup'; +import { START_HIDDEN_ARG, STARTUP_VALUE_NAME, shouldStartHidden, SystemStartupService } from './system-startup'; +import type { StartupRegistryEntry, WindowsStartupRegistry } from './windows-startup-registry'; + +const expectedCommand = '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden'; describe('shouldStartHidden', () => { it('detects the Windows hidden startup argument', () => { @@ -16,94 +19,159 @@ describe('shouldStartHidden', () => { }); describe('SystemStartupService', () => { - it('returns unsupported settings on non-Windows platforms', () => { + it('returns unsupported settings on non-Windows platforms without reading the registry', async () => { const service = createService({ platform: 'darwin', isPackaged: true }); - expect(service.getSettings()).toEqual({ + await expect(service.getSettings()).resolves.toEqual({ supported: false, startWithSystem: false, startsHidden: true, reason: 'unsupported_platform' }); - service.setStartWithSystem(true); - expect(service.setLoginItemSettings).not.toHaveBeenCalled(); + await service.setStartWithSystem(true); + expect(service.startupRegistry.getEntry).not.toHaveBeenCalled(); + expect(service.startupRegistry.setEntry).not.toHaveBeenCalled(); }); - it('returns unsupported settings for unpackaged Windows builds', () => { + it('returns unsupported settings for unpackaged Windows builds without writing the registry', async () => { const service = createService({ platform: 'win32', isPackaged: false }); - expect(service.getSettings()).toEqual({ + await expect(service.getSettings()).resolves.toEqual({ supported: false, startWithSystem: false, startsHidden: true, reason: 'unpackaged' }); - service.setStartWithSystem(true); - expect(service.setLoginItemSettings).not.toHaveBeenCalled(); + await service.setStartWithSystem(true); + expect(service.startupRegistry.setEntry).not.toHaveBeenCalled(); }); - it('reports enabled startup when the registered executable will launch at login', () => { + it('reports enabled startup when the expected command is registered and approved', async () => { const service = createService({ - loginItemSettings: { - openAtLogin: true, - executableWillLaunchAtLogin: true + entry: { + command: expectedCommand, + startupApproved: 'enabled' } }); - expect(service.getSettings()).toEqual({ + await expect(service.getSettings()).resolves.toEqual({ supported: true, startWithSystem: true, startsHidden: true, - reason: null + reason: null, + registration: { + expectedCommand, + registeredCommand: expectedCommand, + startupApproved: 'enabled' + } }); - expect(service.getLoginItemSettings).toHaveBeenCalledWith({ - path: 'C:\\Program Files\\Switchify PC\\Switchify PC.exe', - args: [START_HIDDEN_ARG] + expect(service.startupRegistry.getEntry).toHaveBeenCalledWith(STARTUP_VALUE_NAME); + }); + + it('reports enabled startup when StartupApproved is missing but the command matches', async () => { + const service = createService({ + entry: { + command: expectedCommand, + startupApproved: 'missing' + } + }); + + await expect(service.getSettings()).resolves.toMatchObject({ + supported: true, + startWithSystem: true, + registration: { + startupApproved: 'missing' + } }); }); - it('reports disabled startup when Windows will not launch the executable', () => { + it('reports disabled startup when StartupApproved disables the matching command', async () => { const service = createService({ - loginItemSettings: { - openAtLogin: true, - executableWillLaunchAtLogin: false + entry: { + command: expectedCommand, + startupApproved: 'disabled' } }); - expect(service.getSettings()).toMatchObject({ + await expect(service.getSettings()).resolves.toMatchObject({ supported: true, - startWithSystem: false + startWithSystem: false, + registration: { + registeredCommand: expectedCommand, + startupApproved: 'disabled' + } }); }); - it('enables startup with the hidden startup argument', () => { - const service = createService(); + it('reports disabled startup when the Run command is missing', async () => { + const service = createService({ + entry: { + command: null, + startupApproved: 'missing' + } + }); - service.setStartWithSystem(true); + await expect(service.getSettings()).resolves.toMatchObject({ + supported: true, + startWithSystem: false, + registration: { + expectedCommand, + registeredCommand: null, + startupApproved: 'missing' + } + }); + }); + + it('reports disabled startup when the Run command points to an older path', async () => { + const oldCommand = '"C:\\Old\\Switchify PC.exe" --start-hidden'; + const service = createService({ + entry: { + command: oldCommand, + startupApproved: 'enabled' + } + }); - expect(service.setLoginItemSettings).toHaveBeenCalledWith({ - openAtLogin: true, - path: 'C:\\Program Files\\Switchify PC\\Switchify PC.exe', - args: [START_HIDDEN_ARG], - name: 'app.switchify.pc', - enabled: true + await expect(service.getSettings()).resolves.toMatchObject({ + supported: true, + startWithSystem: false, + registration: { + expectedCommand, + registeredCommand: oldCommand, + startupApproved: 'enabled' + } }); }); - it('disables startup without leaving a disabled login item entry', () => { + it('enables startup by writing the expected command', async () => { + const service = createService(); + + await service.setStartWithSystem(true); + + expect(service.startupRegistry.setEntry).toHaveBeenCalledWith(STARTUP_VALUE_NAME, expectedCommand); + }); + + it('disables startup by deleting the registry entry', async () => { const service = createService(); - service.setStartWithSystem(false); + await service.setStartWithSystem(false); + + expect(service.startupRegistry.deleteEntry).toHaveBeenCalledWith(STARTUP_VALUE_NAME); + }); + + it('returns disabled diagnostics when registry reads fail', async () => { + const service = createService({ getEntryError: new Error('registry unavailable') }); - expect(service.setLoginItemSettings).toHaveBeenCalledWith({ - openAtLogin: false, - path: 'C:\\Program Files\\Switchify PC\\Switchify PC.exe', - args: [START_HIDDEN_ARG], - name: 'app.switchify.pc' + await expect(service.getSettings()).resolves.toMatchObject({ + supported: true, + startWithSystem: false, + registration: { + expectedCommand, + registeredCommand: null, + startupApproved: 'unknown' + } }); - expect(service.setLoginItemSettings.mock.calls[0][0]).not.toHaveProperty('enabled'); }); }); @@ -111,28 +179,34 @@ function createService( options: Partial<{ platform: NodeJS.Platform; isPackaged: boolean; - loginItemSettings: { - openAtLogin: boolean; - executableWillLaunchAtLogin?: boolean; - }; + entry: StartupRegistryEntry; + getEntryError: Error; }> = {} ): SystemStartupService & { - getLoginItemSettings: ReturnType; - setLoginItemSettings: ReturnType; + startupRegistry: { + getEntry: ReturnType; + setEntry: ReturnType; + deleteEntry: ReturnType; + }; } { - const getLoginItemSettings = vi.fn(() => options.loginItemSettings ?? { openAtLogin: false }); - const setLoginItemSettings = vi.fn(); + const startupRegistry: WindowsStartupRegistry & { + getEntry: ReturnType; + setEntry: ReturnType; + deleteEntry: ReturnType; + } = { + getEntry: vi.fn(async () => { + if (options.getEntryError) throw options.getEntryError; + return options.entry ?? { command: null, startupApproved: 'missing' }; + }), + setEntry: vi.fn(async () => undefined), + deleteEntry: vi.fn(async () => undefined) + }; const service = new SystemStartupService({ platform: options.platform ?? 'win32', isPackaged: options.isPackaged ?? true, executablePath: 'C:\\Program Files\\Switchify PC\\Switchify PC.exe', - appUserModelId: 'app.switchify.pc', - getLoginItemSettings, - setLoginItemSettings + startupRegistry }); - return Object.assign(service, { - getLoginItemSettings, - setLoginItemSettings - }); + return Object.assign(service, { startupRegistry }); } diff --git a/src/main/system-startup.ts b/src/main/system-startup.ts index b93067f..8ddeac4 100644 --- a/src/main/system-startup.ts +++ b/src/main/system-startup.ts @@ -1,32 +1,14 @@ import type { SystemStartupSettings } from '../shared/system-startup'; +import { startupCommandFor, type WindowsStartupRegistry } from './windows-startup-registry'; export const START_HIDDEN_ARG = '--start-hidden'; - -type LoginItemQueryOptions = { - path: string; - args: string[]; -}; - -type LoginItemSettingsResult = { - openAtLogin: boolean; - executableWillLaunchAtLogin?: boolean; -}; - -type LoginItemUpdateSettings = { - openAtLogin: boolean; - path: string; - args: string[]; - name: string; - enabled?: boolean; -}; +export const STARTUP_VALUE_NAME = 'app.switchify.pc'; export type SystemStartupServiceOptions = { platform: NodeJS.Platform; isPackaged: boolean; executablePath: string; - appUserModelId: string; - getLoginItemSettings: (options: LoginItemQueryOptions) => LoginItemSettingsResult; - setLoginItemSettings: (settings: LoginItemUpdateSettings) => void; + startupRegistry: WindowsStartupRegistry; }; export function shouldStartHidden(argv: string[], platform: NodeJS.Platform): boolean { @@ -36,32 +18,38 @@ export function shouldStartHidden(argv: string[], platform: NodeJS.Platform): bo export class SystemStartupService { constructor(private readonly options: SystemStartupServiceOptions) {} - getSettings(): SystemStartupSettings { + async getSettings(): Promise { if (!this.isSupported()) { return this.unsupportedSettings(); } - const loginItemSettings = this.options.getLoginItemSettings(this.loginItemQueryOptions()); + const expectedCommand = this.expectedCommand(); + const entry = await this.getRegistryEntrySafely(); + return { supported: true, - startWithSystem: loginItemSettings.openAtLogin && loginItemSettings.executableWillLaunchAtLogin !== false, + startWithSystem: entry.command === expectedCommand && entry.startupApproved !== 'disabled', startsHidden: true, - reason: null + reason: null, + registration: { + expectedCommand, + registeredCommand: entry.command, + startupApproved: entry.startupApproved + } }; } - setStartWithSystem(enabled: boolean): SystemStartupSettings { + async setStartWithSystem(enabled: boolean): Promise { if (!this.isSupported()) { return this.unsupportedSettings(); } - this.options.setLoginItemSettings({ - openAtLogin: enabled, - path: this.options.executablePath, - args: [START_HIDDEN_ARG], - name: this.options.appUserModelId, - ...(enabled ? { enabled: true } : {}) - }); + if (enabled) { + await this.options.startupRegistry.setEntry(STARTUP_VALUE_NAME, this.expectedCommand()); + } else { + await this.options.startupRegistry.deleteEntry(STARTUP_VALUE_NAME); + } + return this.getSettings(); } @@ -69,11 +57,20 @@ export class SystemStartupService { return this.options.platform === 'win32' && this.options.isPackaged; } - private loginItemQueryOptions(): LoginItemQueryOptions { - return { - path: this.options.executablePath, - args: [START_HIDDEN_ARG] - }; + private expectedCommand(): string { + return startupCommandFor(this.options.executablePath, [START_HIDDEN_ARG]); + } + + private async getRegistryEntrySafely(): Promise<{ + command: string | null; + startupApproved: 'enabled' | 'disabled' | 'missing' | 'unknown'; + }> { + try { + return await this.options.startupRegistry.getEntry(STARTUP_VALUE_NAME); + } catch (error) { + console.warn(error instanceof Error ? error.message : 'Could not read startup registry settings.'); + return { command: null, startupApproved: 'unknown' }; + } } private unsupportedSettings(): SystemStartupSettings { diff --git a/src/main/windows-startup-registry.test.ts b/src/main/windows-startup-registry.test.ts new file mode 100644 index 0000000..5d1d776 --- /dev/null +++ b/src/main/windows-startup-registry.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createWindowsStartupRegistry, + startupCommandFor, + type CommandRunner +} from './windows-startup-registry'; + +describe('startupCommandFor', () => { + it('quotes the executable path and appends arguments', () => { + expect(startupCommandFor('C:\\Program Files\\Switchify PC\\Switchify PC.exe', ['--start-hidden'])).toBe( + '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden' + ); + }); + + it('rejects executable paths containing quotes', () => { + expect(() => startupCommandFor('C:\\Bad"Path\\Switchify PC.exe', ['--start-hidden'])).toThrow( + 'Startup executable path cannot contain quotes.' + ); + }); + + it('rejects arguments containing quotes', () => { + expect(() => startupCommandFor('C:\\Switchify PC.exe', ['--bad"arg'])).toThrow( + 'Startup arguments cannot contain quotes.' + ); + }); +}); + +describe('createWindowsStartupRegistry', () => { + it('returns command and enabled StartupApproved state', async () => { + const runner = createRunner({ + runQuery: ' app.switchify.pc REG_SZ "C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden', + approvedQuery: ' app.switchify.pc REG_BINARY 020000000000000000000000' + }); + + await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toEqual({ + command: '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden', + startupApproved: 'enabled' + }); + }); + + it('returns null command when the Run value is missing', async () => { + const runner = createRunner({ + runMissing: true, + approvedQuery: ' app.switchify.pc REG_BINARY 020000000000000000000000' + }); + + await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toEqual({ + command: null, + startupApproved: 'enabled' + }); + }); + + it('returns missing StartupApproved when that value is missing', async () => { + const runner = createRunner({ + runQuery: ' app.switchify.pc REG_SZ "C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden', + approvedMissing: true + }); + + await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toEqual({ + command: '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden', + startupApproved: 'missing' + }); + }); + + it('parses disabled StartupApproved state', async () => { + const runner = createRunner({ + runQuery: ' app.switchify.pc REG_SZ "C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden', + approvedQuery: ' app.switchify.pc REG_BINARY 030000000000000000000000' + }); + + await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toMatchObject({ + startupApproved: 'disabled' + }); + }); + + it('parses unknown StartupApproved state', async () => { + const runner = createRunner({ + runQuery: ' app.switchify.pc REG_SZ "C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden', + approvedQuery: ' app.switchify.pc REG_BINARY 090000000000000000000000' + }); + + await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toMatchObject({ + startupApproved: 'unknown' + }); + }); + + it('writes Run and StartupApproved values when setting an entry', async () => { + const runner = vi.fn(async () => ({ stdout: '', stderr: '' })); + + await createWindowsStartupRegistry(runner).setEntry('app.switchify.pc', '"C:\\Switchify PC.exe" --start-hidden'); + + expect(runner).toHaveBeenCalledWith('reg.exe', [ + 'add', + 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', + '/v', + 'app.switchify.pc', + '/t', + 'REG_SZ', + '/d', + '"C:\\Switchify PC.exe" --start-hidden', + '/f' + ]); + expect(runner).toHaveBeenCalledWith('reg.exe', [ + 'add', + 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run', + '/v', + 'app.switchify.pc', + '/t', + 'REG_BINARY', + '/d', + '020000000000000000000000', + '/f' + ]); + }); + + it('deletes both Run and StartupApproved values and ignores missing values', async () => { + const runner = vi.fn(async () => { + throw Object.assign(new Error('The system was unable to find the specified registry key or value.'), { + code: 1 + }); + }); + + await expect(createWindowsStartupRegistry(runner).deleteEntry('app.switchify.pc')).resolves.toBeUndefined(); + expect(runner).toHaveBeenCalledTimes(2); + }); +}); + +function createRunner(options: { + runQuery?: string; + approvedQuery?: string; + runMissing?: boolean; + approvedMissing?: boolean; +}): CommandRunner { + return vi.fn(async (_file, args) => { + const key = args[1]; + if (key === 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run') { + if (options.runMissing) throw missingRegistryValueError(); + return { stdout: options.runQuery ?? '', stderr: '' }; + } + + if (key === 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run') { + if (options.approvedMissing) throw missingRegistryValueError(); + return { stdout: options.approvedQuery ?? '', stderr: '' }; + } + + throw new Error(`Unexpected registry key: ${key}`); + }); +} + +function missingRegistryValueError(): Error & { code: number } { + return Object.assign(new Error('The system was unable to find the specified registry key or value.'), { code: 1 }); +} diff --git a/src/main/windows-startup-registry.ts b/src/main/windows-startup-registry.ts new file mode 100644 index 0000000..bb37a3d --- /dev/null +++ b/src/main/windows-startup-registry.ts @@ -0,0 +1,138 @@ +import { execFile } from 'node:child_process'; + +export type StartupApprovedState = 'enabled' | 'disabled' | 'missing' | 'unknown'; + +export type StartupRegistryEntry = { + command: string | null; + startupApproved: StartupApprovedState; +}; + +export type CommandRunner = ( + file: string, + args: string[] +) => Promise<{ stdout: string; stderr: string }>; + +export type WindowsStartupRegistry = { + getEntry(valueName: string): Promise; + setEntry(valueName: string, command: string): Promise; + deleteEntry(valueName: string): Promise; +}; + +const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; +const STARTUP_APPROVED_RUN_KEY = + 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run'; +const STARTUP_APPROVED_ENABLED_HEX = '020000000000000000000000'; + +export function createWindowsStartupRegistry(commandRunner: CommandRunner = runCommand): WindowsStartupRegistry { + return { + async getEntry(valueName) { + const command = await queryRunCommand(commandRunner, valueName); + const startupApproved = await queryStartupApproved(commandRunner, valueName); + return { command, startupApproved }; + }, + async setEntry(valueName, command) { + await commandRunner('reg.exe', ['add', RUN_KEY, '/v', valueName, '/t', 'REG_SZ', '/d', command, '/f']); + await commandRunner('reg.exe', [ + 'add', + STARTUP_APPROVED_RUN_KEY, + '/v', + valueName, + '/t', + 'REG_BINARY', + '/d', + STARTUP_APPROVED_ENABLED_HEX, + '/f' + ]); + }, + async deleteEntry(valueName) { + await ignoreMissingValue(commandRunner('reg.exe', ['delete', RUN_KEY, '/v', valueName, '/f'])); + await ignoreMissingValue(commandRunner('reg.exe', ['delete', STARTUP_APPROVED_RUN_KEY, '/v', valueName, '/f'])); + } + }; +} + +export function startupCommandFor(executablePath: string, args: string[]): string { + if (executablePath.includes('"')) { + throw new Error('Startup executable path cannot contain quotes.'); + } + + for (const arg of args) { + if (arg.includes('"')) { + throw new Error('Startup arguments cannot contain quotes.'); + } + } + + return [`"${executablePath}"`, ...args].join(' '); +} + +async function queryRunCommand(commandRunner: CommandRunner, valueName: string): Promise { + try { + const { stdout } = await commandRunner('reg.exe', ['query', RUN_KEY, '/v', valueName]); + return parseRegistryValue(stdout, valueName, 'REG_SZ'); + } catch (error) { + if (isMissingRegistryValueError(error)) return null; + throw error; + } +} + +async function queryStartupApproved( + commandRunner: CommandRunner, + valueName: string +): Promise { + try { + const { stdout } = await commandRunner('reg.exe', ['query', STARTUP_APPROVED_RUN_KEY, '/v', valueName]); + const value = parseRegistryValue(stdout, valueName, 'REG_BINARY'); + return startupApprovedStateFromHex(value); + } catch (error) { + if (isMissingRegistryValueError(error)) return 'missing'; + throw error; + } +} + +function parseRegistryValue(stdout: string, valueName: string, valueType: 'REG_SZ' | 'REG_BINARY'): string | null { + const escapedName = valueName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`^\\s*${escapedName}\\s+${valueType}\\s+(.+?)\\s*$`, 'im'); + const match = stdout.match(pattern); + return match?.[1]?.trim() ?? null; +} + +function startupApprovedStateFromHex(value: string | null): StartupApprovedState { + if (!value) return 'unknown'; + + const firstByte = value.replace(/\s+/g, '').slice(0, 2).toLowerCase(); + if (firstByte === '02') return 'enabled'; + if (firstByte === '03') return 'disabled'; + return 'unknown'; +} + +async function ignoreMissingValue(promise: Promise): Promise { + try { + await promise; + } catch (error) { + if (!isMissingRegistryValueError(error)) throw error; + } +} + +function isMissingRegistryValueError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + + const maybeError = error as { code?: unknown; stdout?: unknown; stderr?: unknown; message?: unknown }; + const output = `${String(maybeError.stdout ?? '')}\n${String(maybeError.stderr ?? '')}\n${String( + maybeError.message ?? '' + )}`.toLowerCase(); + + return maybeError.code === 1 || output.includes('unable to find') || output.includes('cannot find'); +} + +function runCommand(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(file, args, { windowsHide: true }, (error, stdout, stderr) => { + if (error) { + reject(Object.assign(error, { stdout, stderr })); + return; + } + + resolve({ stdout, stderr }); + }); + }); +} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index dbc6384..9a1924b 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -172,6 +172,9 @@ function GeneralSettingsSection({ {!systemStartupSettings?.supported ? (
{systemStartupUnavailableMessage(systemStartupSettings?.reason)}
) : null} + {systemStartupSettings?.supported ? ( + + ) : null} ); @@ -496,6 +499,25 @@ function systemStartupUnavailableMessage(reason: SystemStartupSettings['reason'] return 'Start with system is not available on this platform.'; } +function SystemStartupRegistrationMessage({ settings }: { settings: SystemStartupSettings }): ReactElement | null { + if (settings.registration?.startupApproved === 'disabled') { + return
Start with system is disabled in Windows Startup settings.
; + } + + if ( + settings.registration?.registeredCommand && + settings.registration.registeredCommand !== settings.registration.expectedCommand + ) { + return ( +
+ Start with system is registered to an older app path. Turn it off and on again to repair it. +
+ ); + } + + return null; +} + function PairedDeviceList({ devices, onForgetPairedDevice diff --git a/src/shared/system-startup.ts b/src/shared/system-startup.ts index a1645d6..11d7655 100644 --- a/src/shared/system-startup.ts +++ b/src/shared/system-startup.ts @@ -1,8 +1,14 @@ export type SystemStartupUnavailableReason = 'unsupported_platform' | 'unpackaged'; +export type StartupApprovedState = 'enabled' | 'disabled' | 'missing' | 'unknown'; export type SystemStartupSettings = { supported: boolean; startWithSystem: boolean; startsHidden: boolean; reason: SystemStartupUnavailableReason | null; + registration?: { + expectedCommand: string; + registeredCommand: string | null; + startupApproved: StartupApprovedState; + }; }; From 55e4c78ddfd7a473301951afcb10c97e746a4fe4 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 24 Jun 2026 12:16:07 +0100 Subject: [PATCH 2/2] Fix startup test fixture typing --- src/main/system-startup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/system-startup.test.ts b/src/main/system-startup.test.ts index bf52510..9cdd66e 100644 --- a/src/main/system-startup.test.ts +++ b/src/main/system-startup.test.ts @@ -196,7 +196,7 @@ function createService( } = { getEntry: vi.fn(async () => { if (options.getEntryError) throw options.getEntryError; - return options.entry ?? { command: null, startupApproved: 'missing' }; + return options.entry ?? ({ command: null, startupApproved: 'missing' } satisfies StartupRegistryEntry); }), setEntry: vi.fn(async () => undefined), deleteEntry: vi.fn(async () => undefined)