From ceda663708d83a87af9b810818245b7372bfe5f6 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 27 Jun 2026 18:52:42 +0100 Subject: [PATCH] Fix update installer handoff (#267) --- .github/workflows/release.yml | 6 + electron-builder.yml | 2 + .../update-launcher-helper/NativeMethods.cs | 99 ++++++++++++ native/update-launcher-helper/Program.cs | 131 +++++++++++++++ .../SwitchifyUpdateLauncher.csproj | 14 ++ .../UpdateLauncherResult.cs | 20 +++ package.json | 2 + scripts/build-cursor-overlay-helper.cjs | 6 + scripts/package-win-after-pack.cjs | 10 +- scripts/sign-win-artifacts.cjs | 87 +++++++++- scripts/smoke-update-launcher-helper.cjs | 24 +++ scripts/verify-latest-yml-admin-rights.cjs | 18 +++ scripts/verify-win-uiaccess-package.cjs | 63 +++----- src/main/index.ts | 1 + src/main/package-win-after-pack.test.ts | 9 +- src/main/sign-win-artifacts.test.ts | 43 +++++ .../update-install-diagnostics.test.ts | 84 ++++++++++ .../updates/update-install-diagnostics.ts | 44 +++++ .../updates/update-installer-launcher.test.ts | 93 ++++++----- src/main/updates/update-installer-launcher.ts | 153 ++++++++++++++---- src/main/updates/update-ipc.test.ts | 4 +- src/main/updates/update-ipc.ts | 1 + src/main/updates/update-service.test.ts | 121 ++++++++++++++ src/main/updates/update-service.ts | 67 +++++++- src/renderer/updates.test.ts | 22 ++- src/renderer/updates.ts | 10 +- src/shared/update.ts | 7 +- 27 files changed, 1020 insertions(+), 121 deletions(-) create mode 100644 native/update-launcher-helper/NativeMethods.cs create mode 100644 native/update-launcher-helper/Program.cs create mode 100644 native/update-launcher-helper/SwitchifyUpdateLauncher.csproj create mode 100644 native/update-launcher-helper/UpdateLauncherResult.cs create mode 100644 scripts/smoke-update-launcher-helper.cjs create mode 100644 scripts/verify-latest-yml-admin-rights.cjs create mode 100644 src/main/updates/update-install-diagnostics.test.ts create mode 100644 src/main/updates/update-install-diagnostics.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 672016b..c173a39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,9 +88,15 @@ jobs: - name: Smoke native cursor overlay helper run: npm run native:smoke-overlay + - name: Smoke native update launcher helper + run: npm run native:smoke-update-launcher + - name: Package Windows installer run: npm run package:win + - name: Verify updater metadata + run: npm run package:win:verify-updater-metadata + - name: Verify release tag matches package version shell: powershell run: | diff --git a/electron-builder.yml b/electron-builder.yml index 60d990e..06a7ab4 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -32,6 +32,8 @@ extraResources: to: native/SwitchifyBluetoothTransport.exe - from: build/native/text-input-helper/win-x64/SwitchifyTextInput.exe to: native/SwitchifyTextInput.exe + - from: build/native/update-launcher-helper/win-x64/SwitchifyUpdateLauncher.exe + to: native/SwitchifyUpdateLauncher.exe win: icon: build/icon.ico diff --git a/native/update-launcher-helper/NativeMethods.cs b/native/update-launcher-helper/NativeMethods.cs new file mode 100644 index 0000000..ff46c45 --- /dev/null +++ b/native/update-launcher-helper/NativeMethods.cs @@ -0,0 +1,99 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +namespace Switchify.UpdateLauncher; + +internal static class NativeMethods +{ + internal const uint SeeMaskNoCloseProcess = 0x00000040; + internal const int SwShownormal = 1; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal sealed class ShellExecuteInfo + { + public int cbSize = Marshal.SizeOf(); + public uint fMask; + public IntPtr hwnd; + public string? lpVerb; + public string? lpFile; + public string? lpParameters; + public string? lpDirectory; + public int nShow; + public IntPtr hInstApp; + public IntPtr lpIDList; + public string? lpClass; + public IntPtr hkeyClass; + public uint dwHotKey; + public IntPtr hIcon; + public IntPtr hProcess; + } + + [DllImport("shell32.dll", EntryPoint = "ShellExecuteExW", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool ShellExecuteEx(ShellExecuteInfo info); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern uint GetProcessId(IntPtr process); + + internal static string JoinCommandLineArguments(IEnumerable args) + { + return string.Join(" ", args.Select(QuoteCommandLineArgument)); + } + + private static string QuoteCommandLineArgument(string value) + { + if (value.Length == 0) + { + return "\"\""; + } + + if (!value.Any(char.IsWhiteSpace) && !value.Contains('"') && !value.Contains('\\')) + { + return value; + } + + var builder = new StringBuilder(); + builder.Append('"'); + var backslashCount = 0; + + foreach (var character in value) + { + if (character == '\\') + { + backslashCount++; + continue; + } + + if (character == '"') + { + builder.Append('\\', backslashCount * 2 + 1); + builder.Append('"'); + backslashCount = 0; + continue; + } + + builder.Append('\\', backslashCount); + builder.Append(character); + backslashCount = 0; + } + + builder.Append('\\', backslashCount * 2); + builder.Append('"'); + return builder.ToString(); + } + + internal static int GetLastWin32Error() + { + return Marshal.GetLastWin32Error(); + } + + internal static string Win32Message(int error) + { + return new Win32Exception(error).Message; + } +} diff --git a/native/update-launcher-helper/Program.cs b/native/update-launcher-helper/Program.cs new file mode 100644 index 0000000..e46a6d5 --- /dev/null +++ b/native/update-launcher-helper/Program.cs @@ -0,0 +1,131 @@ +using System.Text.Json; + +namespace Switchify.UpdateLauncher; + +internal static class Program +{ + private const int ErrorCancelled = 1223; + + private static int Main(string[] args) + { + try + { + if (args.Length == 1 && args[0] == "--self-test-quote") + { + Console.Out.WriteLine(NativeMethods.JoinCommandLineArguments(new[] { "--updated", "--force-run", "value with spaces" })); + return 0; + } + + var parsed = ParseArgs(args); + if (parsed is null) + { + WriteResult(false, "invalid_arguments"); + return 2; + } + + if (!File.Exists(parsed.InstallerPath)) + { + WriteResult(false, "installer_missing"); + return 3; + } + + var parameters = NativeMethods.JoinCommandLineArguments(parsed.InstallerArgs); + var info = new NativeMethods.ShellExecuteInfo + { + fMask = NativeMethods.SeeMaskNoCloseProcess, + lpVerb = "runas", + lpFile = parsed.InstallerPath, + lpParameters = parameters, + nShow = NativeMethods.SwShownormal + }; + + if (!NativeMethods.ShellExecuteEx(info)) + { + var error = NativeMethods.GetLastWin32Error(); + WriteResult(false, error == ErrorCancelled ? "uac_cancelled" : "launch_failed", null, error); + return error == ErrorCancelled ? 4 : 5; + } + + if (info.hProcess == IntPtr.Zero) + { + WriteResult(false, "installer_process_unavailable"); + return 6; + } + + try + { + var pid = NativeMethods.GetProcessId(info.hProcess); + WriteResult(true, "installer_started", pid == 0 ? null : checked((int)pid)); + return 0; + } + finally + { + NativeMethods.CloseHandle(info.hProcess); + } + } + catch + { + WriteResult(false, "unexpected_error"); + return 10; + } + } + + private static ParsedArgs? ParseArgs(string[] args) + { + string? installerPath = null; + string? argsJson = null; + + for (var index = 0; index < args.Length; index++) + { + var arg = args[index]; + if (arg == "--installer" && index + 1 < args.Length) + { + installerPath = args[++index]; + continue; + } + + if (arg == "--args-json" && index + 1 < args.Length) + { + argsJson = args[++index]; + continue; + } + + return null; + } + + if (string.IsNullOrWhiteSpace(installerPath) || string.IsNullOrWhiteSpace(argsJson)) + { + return null; + } + + string[]? installerArgs; + try + { + installerArgs = JsonSerializer.Deserialize(argsJson); + } + catch (JsonException) + { + return null; + } + + if (installerArgs is null || installerArgs.Any((value) => value is null)) + { + return null; + } + + return new ParsedArgs(installerPath, installerArgs); + } + + private static void WriteResult(bool ok, string status, int? pid = null, int? win32Error = null) + { + Console.Out.WriteLine(JsonSerializer.Serialize(new UpdateLauncherResult + { + Ok = ok, + Status = status, + Pid = pid, + Win32Error = win32Error + })); + } + + private sealed record ParsedArgs(string InstallerPath, string[] InstallerArgs); +} diff --git a/native/update-launcher-helper/SwitchifyUpdateLauncher.csproj b/native/update-launcher-helper/SwitchifyUpdateLauncher.csproj new file mode 100644 index 0000000..df05be3 --- /dev/null +++ b/native/update-launcher-helper/SwitchifyUpdateLauncher.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0-windows + enable + enable + SwitchifyUpdateLauncher + Switchify.UpdateLauncher + win-x64 + true + true + false + + diff --git a/native/update-launcher-helper/UpdateLauncherResult.cs b/native/update-launcher-helper/UpdateLauncherResult.cs new file mode 100644 index 0000000..6faab44 --- /dev/null +++ b/native/update-launcher-helper/UpdateLauncherResult.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Switchify.UpdateLauncher; + +internal sealed class UpdateLauncherResult +{ + [JsonPropertyName("ok")] + public required bool Ok { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("pid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Pid { get; init; } + + [JsonPropertyName("win32Error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Win32Error { get; init; } +} diff --git a/package.json b/package.json index 603205b..b083704 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,10 @@ "native:build-overlay": "npm run native:build", "native:smoke-overlay": "node scripts/smoke-cursor-overlay-helper.cjs", "native:smoke-text": "node scripts/smoke-text-input-helper.cjs", + "native:smoke-update-launcher": "node scripts/smoke-update-launcher-helper.cjs", "package:win": "npm run build && npm run native:build && electron-builder --win --x64 --publish never && node scripts/sign-win-artifacts.cjs --update-latest-yml", "package:win:verify-uiaccess": "node scripts/verify-win-uiaccess-package.cjs", + "package:win:verify-updater-metadata": "node scripts/verify-latest-yml-admin-rights.cjs", "signing:create-dev-cert": "node scripts/create-dev-signing-cert.cjs", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "test": "vitest run" diff --git a/scripts/build-cursor-overlay-helper.cjs b/scripts/build-cursor-overlay-helper.cjs index ee98c92..5cc73fa 100644 --- a/scripts/build-cursor-overlay-helper.cjs +++ b/scripts/build-cursor-overlay-helper.cjs @@ -21,6 +21,12 @@ const helpers = [ projectPath: resolveProjectPath('native', 'text-input-helper', 'TextInputHelper.csproj'), outputDir: resolveProjectPath('build', 'native', 'text-input-helper', 'win-x64'), outputExeName: 'SwitchifyTextInput.exe' + }, + { + name: 'update launcher helper', + projectPath: resolveProjectPath('native', 'update-launcher-helper', 'SwitchifyUpdateLauncher.csproj'), + outputDir: resolveProjectPath('build', 'native', 'update-launcher-helper', 'win-x64'), + outputExeName: 'SwitchifyUpdateLauncher.exe' } ]; diff --git a/scripts/package-win-after-pack.cjs b/scripts/package-win-after-pack.cjs index f9aaf3f..519e394 100644 --- a/scripts/package-win-after-pack.cjs +++ b/scripts/package-win-after-pack.cjs @@ -94,8 +94,15 @@ function signWindowsExecutable(filePath) { runTool(signtoolExe, signingArgs); } +const nativeHelperNames = [ + 'SwitchifyCursorOverlay.exe', + 'SwitchifyBluetoothTransport.exe', + 'SwitchifyTextInput.exe', + 'SwitchifyUpdateLauncher.exe' +]; + function signNativeHelpers(appOutDir) { - for (const helperName of ['SwitchifyCursorOverlay.exe', 'SwitchifyBluetoothTransport.exe', 'SwitchifyTextInput.exe']) { + for (const helperName of nativeHelperNames) { const helperPath = path.join(appOutDir, 'resources', 'native', helperName); if (!fs.existsSync(helperPath)) { throw new Error(`Native helper is missing from packaged resources: ${helperPath}`); @@ -238,3 +245,4 @@ function createAzureSigningArgs(filePath, requireSigning) { } module.exports.createSigningArgs = createSigningArgs; +module.exports.nativeHelperNames = nativeHelperNames; diff --git a/scripts/sign-win-artifacts.cjs b/scripts/sign-win-artifacts.cjs index 8155d5f..4f07ca8 100644 --- a/scripts/sign-win-artifacts.cjs +++ b/scripts/sign-win-artifacts.cjs @@ -23,7 +23,8 @@ module.exports = async function signWindowsArtifacts(buildResult) { const installerArtifacts = artifacts.filter((artifactPath) => { const normalized = path.normalize(artifactPath); - return normalized.toLowerCase().endsWith('.exe') && !normalized.toLowerCase().includes(`${path.sep}win-unpacked${path.sep}`); + const lower = normalized.toLowerCase(); + return lower.endsWith('.exe') && !lower.includes(`${path.sep}win-unpacked${path.sep}`) && !lower.endsWith('.__uninstaller.exe'); }); if (installerArtifacts.length === 0) { @@ -68,12 +69,12 @@ function updateLatestYmlForSignedInstaller(installerPath) { return; } - const updated = content.replace(/^(\s*sha512:\s*).+$/gm, `$1${sha512}`); - - if (updated === content) { + if (!/^(\s*sha512:\s*).+$/gm.test(content)) { throw new Error('latest.yml did not contain any sha512 entries to update.'); } + const updated = ensureAdminRightsMetadata(content.replace(/^(\s*sha512:\s*).+$/gm, `$1${sha512}`)); + fs.writeFileSync(latestPath, updated); console.log(`Updated latest.yml sha512 for ${installerName}.`); } @@ -117,6 +118,84 @@ function referencesInstaller(content, installerName) { ); } +function ensureAdminRightsMetadata(content) { + let output = ensureTopLevelAdminRights(content); + output = ensureFileEntryAdminRights(output); + return output; +} + +function ensureTopLevelAdminRights(content) { + if (/^isAdminRightsRequired:\s*(?:true|false)\s*$/m.test(content)) { + return content.replace(/^isAdminRightsRequired:\s*(?:true|false)\s*$/m, 'isAdminRightsRequired: true'); + } + + const releaseDateMatch = content.match(/^releaseDate:\s*.+$/m); + if (releaseDateMatch?.index !== undefined) { + const insertAt = releaseDateMatch.index; + return `${content.slice(0, insertAt)}isAdminRightsRequired: true\n${content.slice(insertAt)}`; + } + + return `${content.replace(/\s*$/, '')}\nisAdminRightsRequired: true\n`; +} + +function ensureFileEntryAdminRights(content) { + const lines = content.split(/\r?\n/); + const result = []; + let inFiles = false; + let inFirstFile = false; + let inserted = false; + let sawAdminLine = false; + + for (const line of lines) { + if (line === 'files:') { + inFiles = true; + result.push(line); + continue; + } + + if (inFiles && /^ -\s+url:/.test(line)) { + if (inFirstFile && !sawAdminLine && !inserted) { + result.push(' isAdminRightsRequired: true'); + inserted = true; + } + inFirstFile = !inserted; + sawAdminLine = false; + result.push(line); + continue; + } + + if (inFirstFile && /^ isAdminRightsRequired:\s*(?:true|false)\s*$/.test(line)) { + result.push(' isAdminRightsRequired: true'); + sawAdminLine = true; + inserted = true; + inFirstFile = false; + continue; + } + + if (inFirstFile && /^ [^ ].*:/.test(line)) { + result.push(line); + continue; + } + + if (inFirstFile && (/^[^ ]/.test(line) || line === '')) { + if (!sawAdminLine && !inserted) { + result.push(' isAdminRightsRequired: true'); + inserted = true; + } + inFirstFile = false; + } + + result.push(line); + } + + if (inFirstFile && !sawAdminLine && !inserted) { + result.push(' isAdminRightsRequired: true'); + } + + return result.join('\n'); +} + module.exports.updateLatestYmlForSignedInstaller = updateLatestYmlForSignedInstaller; module.exports.updateLatestYmlForReferencedInstaller = updateLatestYmlForReferencedInstaller; module.exports.createSha512Base64 = createSha512Base64; +module.exports.ensureAdminRightsMetadata = ensureAdminRightsMetadata; diff --git a/scripts/smoke-update-launcher-helper.cjs b/scripts/smoke-update-launcher-helper.cjs new file mode 100644 index 0000000..6c8f23e --- /dev/null +++ b/scripts/smoke-update-launcher-helper.cjs @@ -0,0 +1,24 @@ +const fs = require('node:fs'); +const { spawnSync } = require('node:child_process'); +const { resolveProjectPath } = require('./win-signing-tools.cjs'); + +const helperPath = resolveProjectPath('build', 'native', 'update-launcher-helper', 'win-x64', 'SwitchifyUpdateLauncher.exe'); +if (!fs.existsSync(helperPath)) { + throw new Error(`Update launcher helper was not built: ${helperPath}`); +} + +const result = spawnSync(helperPath, ['--self-test-quote'], { + encoding: 'utf8', + stdio: 'pipe' +}); + +if (result.status !== 0) { + throw new Error(`Update launcher helper self-test failed with exit code ${result.status ?? 'unknown'}: ${result.stderr}`); +} + +const output = result.stdout.trim(); +if (output !== '--updated --force-run "value with spaces"') { + throw new Error(`Unexpected update launcher self-test output: ${output}`); +} + +console.log('Update launcher helper self-test passed.'); diff --git a/scripts/verify-latest-yml-admin-rights.cjs b/scripts/verify-latest-yml-admin-rights.cjs new file mode 100644 index 0000000..be82efb --- /dev/null +++ b/scripts/verify-latest-yml-admin-rights.cjs @@ -0,0 +1,18 @@ +const fs = require('node:fs'); +const { resolveProjectPath } = require('./win-signing-tools.cjs'); + +const latestPath = resolveProjectPath('dist', 'latest.yml'); +if (!fs.existsSync(latestPath)) { + throw new Error(`latest.yml was not found: ${latestPath}`); +} + +const content = fs.readFileSync(latestPath, 'utf8'); +if (!/^isAdminRightsRequired:\s*true\s*$/m.test(content)) { + throw new Error('latest.yml is missing top-level isAdminRightsRequired: true.'); +} + +if (!/^ isAdminRightsRequired:\s*true\s*$/m.test(content)) { + throw new Error('latest.yml is missing installer file entry isAdminRightsRequired: true.'); +} + +console.log('latest.yml admin-rights metadata: present'); diff --git a/scripts/verify-win-uiaccess-package.cjs b/scripts/verify-win-uiaccess-package.cjs index 73da53f..2b9b21e 100644 --- a/scripts/verify-win-uiaccess-package.cjs +++ b/scripts/verify-win-uiaccess-package.cjs @@ -16,29 +16,23 @@ const executablePath = resolveProjectPath('dist', 'win-unpacked', 'Switchify PC. if (!fs.existsSync(executablePath)) { throw new Error(`Packaged executable not found: ${executablePath}`); } -const helperPath = resolveProjectPath('dist', 'win-unpacked', 'resources', 'native', 'SwitchifyCursorOverlay.exe'); -if (!fs.existsSync(helperPath)) { - throw new Error(`Native cursor overlay helper not found: ${helperPath}`); -} -const bluetoothHelperPath = resolveProjectPath( - 'dist', - 'win-unpacked', - 'resources', - 'native', - 'SwitchifyBluetoothTransport.exe' -); -if (!fs.existsSync(bluetoothHelperPath)) { - throw new Error(`Native Bluetooth transport helper not found: ${bluetoothHelperPath}`); -} -const textInputHelperPath = resolveProjectPath( - 'dist', - 'win-unpacked', - 'resources', - 'native', - 'SwitchifyTextInput.exe' -); -if (!fs.existsSync(textInputHelperPath)) { - throw new Error(`Native text input helper not found: ${textInputHelperPath}`); +const nativeHelpers = [ + ['cursor overlay helper', resolveProjectPath('dist', 'win-unpacked', 'resources', 'native', 'SwitchifyCursorOverlay.exe')], + [ + 'Bluetooth transport helper', + resolveProjectPath('dist', 'win-unpacked', 'resources', 'native', 'SwitchifyBluetoothTransport.exe') + ], + ['text input helper', resolveProjectPath('dist', 'win-unpacked', 'resources', 'native', 'SwitchifyTextInput.exe')], + [ + 'update launcher helper', + resolveProjectPath('dist', 'win-unpacked', 'resources', 'native', 'SwitchifyUpdateLauncher.exe') + ] +]; + +for (const [name, helperPath] of nativeHelpers) { + if (!fs.existsSync(helperPath)) { + throw new Error(`Native ${name} not found: ${helperPath}`); + } } const mtExe = findWindowsSdkTool('mt.exe'); @@ -53,27 +47,16 @@ try { const signatureResult = runTool(signtoolExe, ['verify', '/pa', '/v', executablePath], { stdio: 'pipe' }); const signatureOutput = `${signatureResult.stdout || ''}${signatureResult.stderr || ''}`; - const helperSignatureResult = runTool(signtoolExe, ['verify', '/pa', '/v', helperPath], { stdio: 'pipe' }); - const helperSignatureOutput = `${helperSignatureResult.stdout || ''}${helperSignatureResult.stderr || ''}`; - const bluetoothHelperSignatureResult = runTool(signtoolExe, ['verify', '/pa', '/v', bluetoothHelperPath], { - stdio: 'pipe' - }); - const bluetoothHelperSignatureOutput = `${bluetoothHelperSignatureResult.stdout || ''}${bluetoothHelperSignatureResult.stderr || ''}`; - const textInputHelperSignatureResult = runTool(signtoolExe, ['verify', '/pa', '/v', textInputHelperPath], { - stdio: 'pipe' - }); - const textInputHelperSignatureOutput = `${textInputHelperSignatureResult.stdout || ''}${textInputHelperSignatureResult.stderr || ''}`; - console.log(`manifest embedded: yes`); console.log(`uiAccess=true: ${hasUiAccess ? 'yes' : 'no'}`); console.log(`highestAvailable: ${hasHighestAvailable ? 'yes' : 'no'}`); console.log(`signature status: ${signatureOutput.includes('Successfully verified') ? 'valid' : 'check output above'}`); - console.log('cursor overlay helper: present'); - console.log(`cursor overlay helper signature: ${helperSignatureOutput.includes('Successfully verified') ? 'valid' : 'check output above'}`); - console.log('Bluetooth transport helper: present'); - console.log(`Bluetooth transport helper signature: ${bluetoothHelperSignatureOutput.includes('Successfully verified') ? 'valid' : 'check output above'}`); - console.log('text input helper: present'); - console.log(`text input helper signature: ${textInputHelperSignatureOutput.includes('Successfully verified') ? 'valid' : 'check output above'}`); + for (const [name, helperPath] of nativeHelpers) { + const helperSignatureResult = runTool(signtoolExe, ['verify', '/pa', '/v', helperPath], { stdio: 'pipe' }); + const helperSignatureOutput = `${helperSignatureResult.stdout || ''}${helperSignatureResult.stderr || ''}`; + console.log(`${name}: present`); + console.log(`${name} signature: ${helperSignatureOutput.includes('Successfully verified') ? 'valid' : 'check output above'}`); + } console.log('secure install location required: install per-machine under Program Files for uiAccess to take effect.'); if (!hasUiAccess || !hasHighestAvailable) { diff --git a/src/main/index.ts b/src/main/index.ts index 5308e07..e4b7015 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -366,6 +366,7 @@ if (!gotSingleInstanceLock) { isPackaged: app.isPackaged, platform: process.platform, resourcesPath: process.resourcesPath, + diagnosticsFilePath: join(app.getPath('userData'), 'update-install-diagnostics.jsonl'), autoUpdater, quitApp }) diff --git a/src/main/package-win-after-pack.test.ts b/src/main/package-win-after-pack.test.ts index 2eaae6d..3b2dfb9 100644 --- a/src/main/package-win-after-pack.test.ts +++ b/src/main/package-win-after-pack.test.ts @@ -2,8 +2,9 @@ import { createRequire } from 'node:module'; import { afterEach, describe, expect, it } from 'vitest'; const require = createRequire(import.meta.url); -const { createSigningArgs } = require('../../scripts/package-win-after-pack.cjs') as { +const { createSigningArgs, nativeHelperNames } = require('../../scripts/package-win-after-pack.cjs') as { createSigningArgs: (filePath: string, options: { requireSigning: boolean }) => string[] | null; + nativeHelperNames: string[]; }; const originalEnv = { ...process.env }; @@ -82,6 +83,12 @@ describe('createSigningArgs', () => { }); }); +describe('native helper packaging', () => { + it('includes the update launcher helper in the signed helper list', () => { + expect(nativeHelperNames).toContain('SwitchifyUpdateLauncher.exe'); + }); +}); + function clearSigningEnv(): void { delete process.env.SWITCHIFY_SIGNING_MODE; delete process.env.SWITCHIFY_CERTUM_CERT_THUMBPRINT; diff --git a/src/main/sign-win-artifacts.test.ts b/src/main/sign-win-artifacts.test.ts index 4543b0b..81c2b5d 100644 --- a/src/main/sign-win-artifacts.test.ts +++ b/src/main/sign-win-artifacts.test.ts @@ -7,10 +7,12 @@ import { afterEach, describe, expect, it } from 'vitest'; const require = createRequire(import.meta.url); const { createSha512Base64, + ensureAdminRightsMetadata, updateLatestYmlForReferencedInstaller, updateLatestYmlForSignedInstaller } = require('../../scripts/sign-win-artifacts.cjs') as { createSha512Base64(filePath: string): string; + ensureAdminRightsMetadata(content: string): string; updateLatestYmlForReferencedInstaller(latestPath: string): void; updateLatestYmlForSignedInstaller(installerPath: string): void; }; @@ -48,6 +50,8 @@ describe('sign-win-artifacts updater metadata', () => { const latest = fs.readFileSync(latestPath, 'utf8'); expect(latest.match(/^sha512: .+$/gm)).toEqual([`sha512: ${sha512}`]); expect(latest.match(/^\s+sha512: .+$/gm)).toEqual([` sha512: ${sha512}`]); + expect(latest).toContain('isAdminRightsRequired: true'); + expect(latest).toContain(' isAdminRightsRequired: true'); }); it('ignores latest.yml when it does not reference the signed installer', () => { @@ -93,6 +97,8 @@ describe('sign-win-artifacts updater metadata', () => { const latest = fs.readFileSync(latestPath, 'utf8'); expect(latest.match(/^sha512: .+$/gm)).toEqual([`sha512: ${sha512}`]); expect(latest.match(/^\s+sha512: .+$/gm)).toEqual([` sha512: ${sha512}`]); + expect(latest).toContain('isAdminRightsRequired: true'); + expect(latest).toContain(' isAdminRightsRequired: true'); }); it('does nothing when latest.yml is missing', () => { @@ -127,6 +133,43 @@ describe('sign-win-artifacts updater metadata', () => { expect(latest.match(/^sha512: .+$/gm)).toEqual([`sha512: ${sha512}`]); expect(latest.match(/^\s+sha512: .+$/gm)).toEqual([` sha512: ${sha512}`]); }); + + it('adds admin-rights metadata to latest.yml', () => { + const latest = ensureAdminRightsMetadata( + [ + 'version: 0.1.1', + 'files:', + ' - url: Switchify-PC-Setup-0.1.1-x64.exe', + ' sha512: file-checksum', + 'path: Switchify-PC-Setup-0.1.1-x64.exe', + 'sha512: path-checksum', + "releaseDate: '2026-06-27T12:00:00.000Z'" + ].join('\n') + ); + + expect(latest).toContain('isAdminRightsRequired: true'); + expect(latest).toContain(' isAdminRightsRequired: true'); + expect(latest.indexOf('isAdminRightsRequired: true')).toBeLessThan(latest.indexOf('releaseDate:')); + }); + + it('normalizes false admin-rights metadata to true without duplicating keys', () => { + const latest = ensureAdminRightsMetadata( + [ + 'version: 0.1.1', + 'files:', + ' - url: Switchify-PC-Setup-0.1.1-x64.exe', + ' sha512: file-checksum', + ' isAdminRightsRequired: false', + 'path: Switchify-PC-Setup-0.1.1-x64.exe', + 'sha512: path-checksum', + 'isAdminRightsRequired: false' + ].join('\n') + ); + + expect(latest.match(/^isAdminRightsRequired:/gm)).toEqual(['isAdminRightsRequired:']); + expect(latest.match(/^ isAdminRightsRequired:/gm)).toEqual([' isAdminRightsRequired:']); + expect(latest).not.toContain('isAdminRightsRequired: false'); + }); }); function createTempDir(): string { diff --git a/src/main/updates/update-install-diagnostics.test.ts b/src/main/updates/update-install-diagnostics.test.ts new file mode 100644 index 0000000..b56c86e --- /dev/null +++ b/src/main/updates/update-install-diagnostics.test.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { appendUpdateInstallDiagnostic } from './update-install-diagnostics'; + +const tempDirs: string[] = []; + +describe('appendUpdateInstallDiagnostic', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + it('appends a JSONL diagnostics entry', () => { + const filePath = tempFile(); + + appendUpdateInstallDiagnostic(filePath, { + event: 'install_requested', + at: '2026-06-27T12:00:00.000Z', + version: '0.1.18' + }); + + const lines = readFileSync(filePath, 'utf8').trim().split(/\r?\n/); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toEqual({ + event: 'install_requested', + at: '2026-06-27T12:00:00.000Z', + version: '0.1.18' + }); + }); + + it('keeps only the newest 100 lines', () => { + const filePath = tempFile(); + + for (let index = 0; index < 105; index++) { + appendUpdateInstallDiagnostic(filePath, { + event: 'installer_launch_failed', + at: `2026-06-27T12:00:${String(index).padStart(2, '0')}.000Z`, + version: '0.1.18', + reason: String(index) + }); + } + + const lines = readFileSync(filePath, 'utf8').trim().split(/\r?\n/); + expect(lines).toHaveLength(100); + expect(JSON.parse(lines[0]).reason).toBe('5'); + expect(JSON.parse(lines.at(-1) ?? '{}').reason).toBe('104'); + }); + + it('does not require sensitive fields', () => { + const filePath = tempFile(); + + appendUpdateInstallDiagnostic(filePath, { + event: 'uac_cancelled', + at: '2026-06-27T12:00:00.000Z', + version: '0.1.18' + }); + + const entry = JSON.parse(readFileSync(filePath, 'utf8').trim()) as Record; + expect(entry).not.toHaveProperty('installerPath'); + expect(entry).not.toHaveProperty('thumbprint'); + }); + + it('swallows write failures with a concise warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + appendUpdateInstallDiagnostic('', { + event: 'install_requested', + at: '2026-06-27T12:00:00.000Z', + version: '0.1.18' + }); + + expect(warn).toHaveBeenCalled(); + }); +}); + +function tempFile(): string { + const dir = mkdtempSync(join(tmpdir(), 'switchify-update-diagnostics-')); + tempDirs.push(dir); + return join(dir, 'nested', 'update-install-diagnostics.jsonl'); +} diff --git a/src/main/updates/update-install-diagnostics.ts b/src/main/updates/update-install-diagnostics.ts new file mode 100644 index 0000000..02b9220 --- /dev/null +++ b/src/main/updates/update-install-diagnostics.ts @@ -0,0 +1,44 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export type UpdateInstallDiagnosticEvent = + | 'install_requested' + | 'confirmation_cancelled' + | 'installer_missing' + | 'launcher_missing' + | 'uac_cancelled' + | 'installer_launch_failed' + | 'installer_process_unavailable' + | 'installer_started' + | 'cache_cleanup_failed'; + +export type UpdateInstallDiagnosticEntry = { + event: UpdateInstallDiagnosticEvent; + at: string; + version: string; + reason?: string; +}; + +const MAX_DIAGNOSTIC_LINES = 100; + +export function appendUpdateInstallDiagnostic(filePath: string, entry: UpdateInstallDiagnosticEntry): void { + try { + mkdirSync(dirname(filePath), { recursive: true }); + const existing = readExistingLines(filePath); + const lines = [...existing, JSON.stringify(entry)].slice(-MAX_DIAGNOSTIC_LINES); + writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + } catch (error) { + console.warn(error instanceof Error ? error.message : 'Switchify update install diagnostics could not be written.'); + } +} + +function readExistingLines(filePath: string): string[] { + try { + return readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + } catch { + return []; + } +} diff --git a/src/main/updates/update-installer-launcher.test.ts b/src/main/updates/update-installer-launcher.test.ts index 75f9e27..e4a806a 100644 --- a/src/main/updates/update-installer-launcher.test.ts +++ b/src/main/updates/update-installer-launcher.test.ts @@ -1,4 +1,5 @@ import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { launchWindowsUpdateInstaller, @@ -8,8 +9,8 @@ import { } from './update-installer-launcher'; class FakeSpawnProcess implements SpawnProcess { - pid = 1234; - readonly unref = vi.fn(); + stdout = new PassThrough(); + stderr = new PassThrough(); private readonly errorListeners: Array<(error: Error) => void> = []; private readonly exitListeners: Array<(code: number | null, signal: NodeJS.Signals | null) => void> = []; @@ -27,6 +28,10 @@ class FakeSpawnProcess implements SpawnProcess { this.exitListeners.push(listener as (code: number | null, signal: NodeJS.Signals | null) => void); } + writeStdout(value: string): void { + this.stdout.write(value); + } + emitError(error = new Error('failed')): void { for (const listener of this.errorListeners) { listener(error); @@ -55,38 +60,36 @@ describe('launchWindowsUpdateInstaller', () => { it('returns installer_unavailable when installer file is missing', async () => { await expect( launch({ - fileExists: (path) => path.endsWith('elevate.exe') + fileExists: (path) => path.endsWith('SwitchifyUpdateLauncher.exe') }) ).resolves.toEqual({ ok: false, reason: 'installer_unavailable' }); }); - it('returns elevation_helper_unavailable when elevate.exe is missing', async () => { + it('returns update_launcher_unavailable when the launcher is missing', async () => { await expect( launch({ fileExists: (path) => path.endsWith('installer.exe') }) - ).resolves.toEqual({ ok: false, reason: 'elevation_helper_unavailable' }); + ).resolves.toEqual({ ok: false, reason: 'update_launcher_unavailable' }); }); - it('spawns elevate.exe with installer arguments', async () => { - vi.useFakeTimers(); + it('spawns the packaged update launcher with installer arguments', async () => { const child = new FakeSpawnProcess(); const spawnInstaller = vi.fn(() => child); - const resultPromise = launch({ child, spawnInstaller, settleMs: 10 }); - await vi.advanceTimersByTimeAsync(10); + const resultPromise = launch({ child, spawnInstaller }); + child.writeStdout('{"ok":true,"status":"installer_started","pid":1234}\n'); + child.emitExit(0); await expect(resultPromise).resolves.toEqual({ ok: true, pid: 1234 }); expect(spawnInstaller).toHaveBeenCalledWith( - join('resources', 'elevate.exe'), - [join('cache', 'installer.exe'), ...UPDATE_INSTALLER_ARGS], + join('resources', 'native', 'SwitchifyUpdateLauncher.exe'), + ['--installer', join('cache', 'installer.exe'), '--args-json', JSON.stringify(UPDATE_INSTALLER_ARGS)], { - detached: true, - stdio: 'ignore', - windowsHide: false + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true } ); - expect(child.unref).toHaveBeenCalledTimes(1); }); it('returns installer_launch_failed if spawn throws', async () => { @@ -99,62 +102,76 @@ describe('launchWindowsUpdateInstaller', () => { ).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' }); }); - it('returns installer_launch_failed if the child emits error before settling', async () => { - vi.useFakeTimers(); + it('returns installer_launch_failed if the child emits error', async () => { const child = new FakeSpawnProcess(); - const resultPromise = launch({ child, settleMs: 10 }); + const resultPromise = launch({ child }); 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 () => { + it('returns installer_launch_failed if the helper times out', async () => { vi.useFakeTimers(); const child = new FakeSpawnProcess(); - const resultPromise = launch({ child, settleMs: 10 }); + const resultPromise = launch({ child, timeoutMs: 10 }); - child.emitExit(1); + await vi.advanceTimersByTimeAsync(10); 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(); + it('maps helper uac_cancelled output', async () => { const child = new FakeSpawnProcess(); - const resultPromise = launch({ child, settleMs: 10 }); + const resultPromise = launch({ child }); - child.emitExit(0); + child.writeStdout('{"ok":false,"status":"uac_cancelled"}\n'); + child.emitExit(4); - await expect(resultPromise).resolves.toEqual({ ok: true, pid: 1234 }); - expect(child.unref).toHaveBeenCalledTimes(1); + await expect(resultPromise).resolves.toEqual({ ok: false, reason: 'uac_cancelled' }); }); - it('returns success if the child survives the settle period', async () => { - vi.useFakeTimers(); + it('maps helper installer_process_unavailable output', async () => { const child = new FakeSpawnProcess(); - const resultPromise = launch({ child, settleMs: 10 }); + const resultPromise = launch({ child }); - await vi.advanceTimersByTimeAsync(10); + child.writeStdout('{"ok":false,"status":"installer_process_unavailable"}\n'); + child.emitExit(6); - await expect(resultPromise).resolves.toEqual({ ok: true, pid: 1234 }); - expect(child.unref).toHaveBeenCalledTimes(1); + await expect(resultPromise).resolves.toEqual({ ok: false, reason: 'installer_process_unavailable' }); + }); + + it('returns installer_launch_failed for non-zero exit without usable output', async () => { + const child = new FakeSpawnProcess(); + const resultPromise = launch({ child }); + + child.emitExit(5); + + await expect(resultPromise).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' }); + }); + + it('returns update_launcher_invalid_response for invalid success output', async () => { + const child = new FakeSpawnProcess(); + const resultPromise = launch({ child }); + + child.writeStdout('not json\n'); + child.emitExit(0); + + await expect(resultPromise).resolves.toEqual({ ok: false, reason: 'update_launcher_invalid_response' }); }); }); function launch({ installerPath = join('cache', 'installer.exe'), resourcesPath = 'resources', - settleMs = 0, + timeoutMs = 1_000, child = new FakeSpawnProcess(), spawnInstaller = (() => child) as SpawnInstaller, fileExists = () => true }: { installerPath?: string | null; resourcesPath?: string; - settleMs?: number; + timeoutMs?: number; child?: FakeSpawnProcess; spawnInstaller?: SpawnInstaller; fileExists?: (path: string) => boolean; @@ -162,7 +179,7 @@ function launch({ return launchWindowsUpdateInstaller({ installerPath, resourcesPath, - settleMs, + timeoutMs, spawnInstaller, fileExists }); diff --git a/src/main/updates/update-installer-launcher.ts b/src/main/updates/update-installer-launcher.ts index dccc365..0561035 100644 --- a/src/main/updates/update-installer-launcher.ts +++ b/src/main/updates/update-installer-launcher.ts @@ -1,19 +1,23 @@ +import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { spawn } from 'node:child_process'; +import type { Readable } from 'node:stream'; export type UpdateInstallerLaunchReason = | 'installer_unavailable' - | 'elevation_helper_unavailable' - | 'installer_launch_failed'; + | 'update_launcher_unavailable' + | 'uac_cancelled' + | 'installer_launch_failed' + | 'installer_process_unavailable' + | 'update_launcher_invalid_response'; export type UpdateInstallerLaunchResult = | { ok: true; pid: number | null } | { ok: false; reason: UpdateInstallerLaunchReason }; export type SpawnProcess = { - pid?: number; - unref(): void; + stdout: Readable | null; + stderr: Readable | null; once(event: 'error', listener: (error: Error) => void): void; once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void; }; @@ -22,25 +26,31 @@ export type SpawnInstaller = ( command: string, args: string[], options: { - detached: true; - stdio: 'ignore'; - windowsHide: false; + stdio: ['ignore', 'pipe', 'pipe']; + windowsHide: true; } ) => SpawnProcess; +type HelperResult = { + ok?: unknown; + status?: unknown; + pid?: unknown; +}; + export const UPDATE_INSTALLER_ARGS = ['--updated', '--force-run']; -export const INSTALLER_LAUNCH_SETTLE_MS = 1_500; +export const UPDATE_LAUNCHER_TIMEOUT_MS = 30_000; +const OUTPUT_LIMIT_BYTES = 8_192; export async function launchWindowsUpdateInstaller({ installerPath, resourcesPath, - settleMs = INSTALLER_LAUNCH_SETTLE_MS, + timeoutMs = UPDATE_LAUNCHER_TIMEOUT_MS, spawnInstaller = spawn as SpawnInstaller, fileExists = existsSync }: { installerPath: string | null; resourcesPath: string; - settleMs?: number; + timeoutMs?: number; spawnInstaller?: SpawnInstaller; fileExists?: (path: string) => boolean; }): Promise { @@ -48,48 +58,135 @@ export async function launchWindowsUpdateInstaller({ return { ok: false, reason: 'installer_unavailable' }; } - const elevationHelperPath = join(resourcesPath, 'elevate.exe'); - if (!fileExists(elevationHelperPath)) { - return { ok: false, reason: 'elevation_helper_unavailable' }; + const launcherPath = join(resourcesPath, 'native', 'SwitchifyUpdateLauncher.exe'); + if (!fileExists(launcherPath)) { + return { ok: false, reason: 'update_launcher_unavailable' }; } let child: SpawnProcess; try { - child = spawnInstaller(elevationHelperPath, [installerPath, ...UPDATE_INSTALLER_ARGS], { - detached: true, - stdio: 'ignore', - windowsHide: false - }); + child = spawnInstaller( + launcherPath, + ['--installer', installerPath, '--args-json', JSON.stringify(UPDATE_INSTALLER_ARGS)], + { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ); } catch { return { ok: false, reason: 'installer_launch_failed' }; } - return new Promise((resolve) => { + return await waitForLauncherResult(child, timeoutMs); +} + +async function waitForLauncherResult(child: SpawnProcess, timeoutMs: number): Promise { + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (chunk: Buffer | string) => { + stdout = appendLimitedOutput(stdout, chunk); + }); + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr = appendLimitedOutput(stderr, chunk); + }); + + return await new Promise((resolve) => { let settled = false; const timer = setTimeout(() => { - complete({ ok: true, pid: child.pid ?? null }); - }, settleMs); + complete({ ok: false, reason: 'installer_launch_failed' }); + }, timeoutMs); const complete = (result: UpdateInstallerLaunchResult): void => { if (settled) return; settled = true; clearTimeout(timer); - if (result.ok) { - child.unref(); - } + void stderr; 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 }); + if (code !== 0) { + complete({ ok: false, reason: reasonForFailedLauncher(stdout) }); return; } - complete({ ok: false, reason: 'installer_launch_failed' }); + complete(parseSuccessfulLauncherOutput(stdout)); }); }); } + +function parseSuccessfulLauncherOutput(stdout: string): UpdateInstallerLaunchResult { + const line = stdout + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean) + .at(-1); + if (!line) { + return { ok: false, reason: 'update_launcher_invalid_response' }; + } + + let result: HelperResult; + try { + result = JSON.parse(line) as HelperResult; + } catch { + return { ok: false, reason: 'update_launcher_invalid_response' }; + } + + if (result.ok === true && result.status === 'installer_started') { + return { + ok: true, + pid: typeof result.pid === 'number' && Number.isFinite(result.pid) ? result.pid : null + }; + } + + return { ok: false, reason: reasonForStatus(result.status) ?? 'update_launcher_invalid_response' }; +} + +function reasonForFailedLauncher(stdout: string): UpdateInstallerLaunchReason { + const line = stdout + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean) + .at(-1); + if (!line) { + return 'installer_launch_failed'; + } + + try { + const result = JSON.parse(line) as HelperResult; + return reasonForStatus(result.status) ?? 'installer_launch_failed'; + } catch { + return 'installer_launch_failed'; + } +} + +function reasonForStatus(status: unknown): UpdateInstallerLaunchReason | null { + switch (status) { + case 'installer_missing': + return 'installer_unavailable'; + case 'uac_cancelled': + return 'uac_cancelled'; + case 'launch_failed': + case 'unexpected_error': + case 'invalid_arguments': + return 'installer_launch_failed'; + case 'installer_process_unavailable': + return 'installer_process_unavailable'; + default: + return null; + } +} + +function appendLimitedOutput(current: string, chunk: Buffer | string): string { + const next = current + chunk.toString(); + if (Buffer.byteLength(next, 'utf8') <= OUTPUT_LIMIT_BYTES) { + return next; + } + + return next.slice(-OUTPUT_LIMIT_BYTES); +} diff --git a/src/main/updates/update-ipc.test.ts b/src/main/updates/update-ipc.test.ts index 904eae0..a7b50d9 100644 --- a/src/main/updates/update-ipc.test.ts +++ b/src/main/updates/update-ipc.test.ts @@ -66,6 +66,7 @@ describe('registerUpdateIpc', () => { await expect(invokeInstall()).resolves.toEqual({ ok: false, reason: 'cancelled' }); expect(confirmInstallDownloadedUpdate).toHaveBeenCalledTimes(1); expect(updateService.installDownloadedUpdate).not.toHaveBeenCalled(); + expect(updateService.recordInstallCancelled).toHaveBeenCalledTimes(1); }); it('delegates to the update service without confirmation when no update is downloaded', async () => { @@ -127,7 +128,8 @@ function createUpdateService({ })), checkForUpdates: vi.fn(), downloadUpdate: vi.fn(), - installDownloadedUpdate: vi.fn(async () => installResult) + installDownloadedUpdate: vi.fn(async () => installResult), + recordInstallCancelled: vi.fn() } as unknown as UpdateService; } diff --git a/src/main/updates/update-ipc.ts b/src/main/updates/update-ipc.ts index 27c3512..8fea8a7 100644 --- a/src/main/updates/update-ipc.ts +++ b/src/main/updates/update-ipc.ts @@ -49,6 +49,7 @@ export function registerUpdateIpc( } if (!(await confirmInstallDownloadedUpdate(event))) { + updateService.recordInstallCancelled(); return { ok: false, reason: 'cancelled' }; } diff --git a/src/main/updates/update-service.test.ts b/src/main/updates/update-service.test.ts index 5979711..0a0f3b8 100644 --- a/src/main/updates/update-service.test.ts +++ b/src/main/updates/update-service.test.ts @@ -1,4 +1,7 @@ import { 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 type { UpdateInstallerLaunchResult } from './update-installer-launcher'; @@ -241,6 +244,118 @@ describe('UpdateService', () => { error.mockRestore(); }); + it('returns update launcher unavailable and does not quit the app', async () => { + const updater = new FakeUpdater(); + const launchInstaller = vi.fn(async (): Promise => ({ + ok: false, + reason: 'update_launcher_unavailable' + })); + const quitApp = vi.fn(); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + 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, launchInstaller, quitApp }); + await service.checkForUpdates(); + await service.downloadUpdate(); + + await expect(service.installDownloadedUpdate()).resolves.toEqual({ + ok: false, + reason: 'update_launcher_unavailable' + }); + expect(quitApp).not.toHaveBeenCalled(); + error.mockRestore(); + }); + + it('returns UAC cancellation and does not quit the app', async () => { + const updater = new FakeUpdater(); + const launchInstaller = vi.fn(async (): Promise => ({ + ok: false, + reason: 'uac_cancelled' + })); + const quitApp = vi.fn(); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + 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, launchInstaller, quitApp }); + await service.checkForUpdates(); + await service.downloadUpdate(); + + await expect(service.installDownloadedUpdate()).resolves.toEqual({ ok: false, reason: 'uac_cancelled' }); + expect(quitApp).not.toHaveBeenCalled(); + error.mockRestore(); + }); + + it('records install diagnostics without full installer paths', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'switchify-update-service-')); + const diagnosticsFilePath = join(tempDir, 'update-install-diagnostics.jsonl'); + const updater = new FakeUpdater(); + const launchInstaller = vi.fn(async (): Promise => ({ + ok: false, + reason: 'uac_cancelled' + })); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + 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, launchInstaller, diagnosticsFilePath }); + await service.checkForUpdates(); + await service.downloadUpdate(); + + await service.installDownloadedUpdate(); + + const log = readFileSync(diagnosticsFilePath, 'utf8'); + expect(log).toContain('"event":"install_requested"'); + expect(log).toContain('"event":"uac_cancelled"'); + expect(log).not.toContain('C:\\cache\\installer.exe'); + error.mockRestore(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('cleans known updater cache files after successful launcher handoff', async () => { + const updater = new FakeUpdater(); + const removeFile = vi.fn(async () => undefined); + const launchInstaller = vi.fn(async (): Promise => ({ ok: true, pid: 1234 })); + 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:\\Users\\owen\\AppData\\Local\\switchify-pc-updater\\pending\\Switchify-PC-Setup-0.1.1-x64.exe']; + }); + const service = createService({ updater, launchInstaller, removeFile }); + await service.checkForUpdates(); + await service.downloadUpdate(); + + await service.installDownloadedUpdate(); + await Promise.resolve(); + + expect(removeFile).toHaveBeenCalledWith( + 'C:\\Users\\owen\\AppData\\Local\\switchify-pc-updater\\pending\\Switchify-PC-Setup-0.1.1-x64.exe' + ); + expect(removeFile).toHaveBeenCalledWith( + 'C:\\Users\\owen\\AppData\\Local\\switchify-pc-updater\\pending\\update-info.json' + ); + expect(removeFile).toHaveBeenCalledWith('C:\\Users\\owen\\AppData\\Local\\switchify-pc-updater\\installer.exe'); + }); + it('maps updater errors during checks to check_failed', async () => { const updater = new FakeUpdater(); updater.checkForUpdates.mockImplementation(async () => { @@ -279,12 +394,16 @@ function createService({ updater = new FakeUpdater(), launchInstaller = vi.fn(async (): Promise => ({ ok: true, pid: 1234 })), quitApp = vi.fn(), + diagnosticsFilePath = null, + removeFile = vi.fn(async () => undefined), isPackaged = true, platform = 'win32' }: { updater?: FakeUpdater; launchInstaller?: typeof import('./update-installer-launcher').launchWindowsUpdateInstaller; quitApp?: () => void; + diagnosticsFilePath?: string | null; + removeFile?: (path: string) => Promise; isPackaged?: boolean; platform?: NodeJS.Platform; } = {}): UpdateService { @@ -296,6 +415,8 @@ function createService({ autoUpdater: updater, launchInstaller, quitApp, + diagnosticsFilePath, + removeFile, now: () => new Date('2026-06-12T12:00:00.000Z') }); } diff --git a/src/main/updates/update-service.ts b/src/main/updates/update-service.ts index bc26a05..545793b 100644 --- a/src/main/updates/update-service.ts +++ b/src/main/updates/update-service.ts @@ -2,7 +2,10 @@ import { autoUpdater as defaultAutoUpdater } from 'electron-updater'; import type { UpdateInfo as ElectronUpdateInfo, UpdateCheckResult } from 'electron-updater'; import type { UpdateDownloadProgress, UpdateInfo, UpdateInstallResult, UpdateState } from '../../shared/update'; import { createInitialUpdateState } from '../../shared/update'; -import { launchWindowsUpdateInstaller } from './update-installer-launcher'; +import { dirname, join, sep } from 'node:path'; +import { rm } from 'node:fs/promises'; +import { appendUpdateInstallDiagnostic, type UpdateInstallDiagnosticEvent } from './update-install-diagnostics'; +import { launchWindowsUpdateInstaller, type UpdateInstallerLaunchReason } from './update-installer-launcher'; type ElectronDownloadProgress = { transferred?: number; @@ -27,6 +30,8 @@ export type UpdateServiceOptions = { launchInstaller?: typeof launchWindowsUpdateInstaller; quitApp?: () => void; now?: () => Date; + diagnosticsFilePath?: string | null; + removeFile?: (path: string) => Promise; }; type UpdateOperation = 'idle' | 'checking' | 'downloading'; @@ -39,6 +44,8 @@ export class UpdateService { private readonly launchInstaller: typeof launchWindowsUpdateInstaller; private readonly quitApp: () => void; private readonly now: () => Date; + private readonly diagnosticsFilePath: string | null; + private readonly removeFile: (path: string) => Promise; private state: UpdateState; private operation: UpdateOperation = 'idle'; private checkingPromise: Promise | null = null; @@ -53,6 +60,8 @@ export class UpdateService { this.launchInstaller = options.launchInstaller ?? launchWindowsUpdateInstaller; this.quitApp = options.quitApp ?? (() => undefined); this.now = options.now ?? (() => new Date()); + this.diagnosticsFilePath = options.diagnosticsFilePath ?? null; + this.removeFile = options.removeFile ?? ((path) => rm(path, { force: true })); this.state = createInitialUpdateState(options.currentVersion); this.autoUpdater.autoDownload = false; @@ -150,6 +159,7 @@ export class UpdateService { } this.operation = 'downloading'; + this.downloadedInstallerPath = null; this.state = { ...this.state, download: { @@ -188,6 +198,7 @@ export class UpdateService { } async installDownloadedUpdate(): Promise { + this.recordInstallDiagnostic('install_requested'); const unsupportedReason = this.unsupportedReason(); if (unsupportedReason) { return { ok: false, reason: unsupportedReason }; @@ -204,13 +215,20 @@ export class UpdateService { if (!result.ok) { console.error('Switchify update installer could not be started.', result.reason); + this.recordInstallDiagnostic(diagnosticEventForLaunchFailure(result.reason), result.reason); return { ok: false, reason: result.reason }; } + this.recordInstallDiagnostic('installer_started'); + void this.cleanDownloadedInstallerCache(); this.quitApp(); return { ok: true }; } + recordInstallCancelled(): void { + this.recordInstallDiagnostic('confirmation_cancelled'); + } + private registerUpdaterEvents(): void { this.autoUpdater.on('update-available', (rawInfo) => { const info = rawInfo as ElectronUpdateInfo; @@ -227,6 +245,7 @@ export class UpdateService { this.autoUpdater.on('update-not-available', (rawInfo) => { const info = rawInfo as ElectronUpdateInfo; + this.downloadedInstallerPath = null; this.state = { ...this.state, info: updateInfo(this.state.info, info, { @@ -307,12 +326,58 @@ export class UpdateService { if (this.platform !== 'win32') return 'not_supported'; return null; } + + private recordInstallDiagnostic(event: UpdateInstallDiagnosticEvent, reason?: string): void { + if (!this.diagnosticsFilePath) return; + + appendUpdateInstallDiagnostic(this.diagnosticsFilePath, { + event, + at: this.now().toISOString(), + version: this.state.info.currentVersion, + ...(reason ? { reason } : {}) + }); + } + + private async cleanDownloadedInstallerCache(): Promise { + const installerPath = this.downloadedInstallerPath; + if (!installerPath) return; + + try { + const pendingDir = dirname(installerPath); + if (!pendingDir.toLowerCase().endsWith(`${sep}pending`)) { + return; + } + + await this.removeFile(installerPath); + await this.removeFile(join(pendingDir, 'update-info.json')); + await this.removeFile(join(dirname(pendingDir), 'installer.exe')); + } catch { + this.recordInstallDiagnostic('cache_cleanup_failed'); + } + } } function firstInstallerPath(paths: string[]): string | null { return paths.find((path) => path.toLowerCase().endsWith('.exe')) ?? paths[0] ?? null; } +function diagnosticEventForLaunchFailure(reason: UpdateInstallerLaunchReason): UpdateInstallDiagnosticEvent { + switch (reason) { + case 'installer_unavailable': + return 'installer_missing'; + case 'update_launcher_unavailable': + return 'launcher_missing'; + case 'uac_cancelled': + return 'uac_cancelled'; + case 'installer_process_unavailable': + return 'installer_process_unavailable'; + case 'installer_launch_failed': + case 'update_launcher_invalid_response': + return 'installer_launch_failed'; + } +} + + function updateInfo( previous: UpdateInfo, info: ElectronUpdateInfo, diff --git a/src/renderer/updates.test.ts b/src/renderer/updates.test.ts index bdc4617..b7ba727 100644 --- a/src/renderer/updates.test.ts +++ b/src/renderer/updates.test.ts @@ -134,17 +134,33 @@ describe('updateInstallMessage', () => { ); }); - it('explains missing elevation support', () => { - expect(updateInstallMessage('elevation_helper_unavailable')).toBe( - 'The update installer could not request permission to install. Reinstall Switchify PC from the latest installer.' + it('explains missing launcher support', () => { + expect(updateInstallMessage('update_launcher_unavailable')).toBe( + 'The update launcher is missing. Reinstall Switchify PC from the latest installer.' ); }); + it('explains UAC cancellation', () => { + expect(updateInstallMessage('uac_cancelled')).toBe('The update was cancelled before the installer could start.'); + }); + it('explains installer launch failures', () => { expect(updateInstallMessage('installer_launch_failed')).toBe( 'The update installer could not be started. Download the update again or run the installer manually.' ); }); + + it('explains missing installer process confirmation', () => { + expect(updateInstallMessage('installer_process_unavailable')).toBe( + 'Windows did not confirm that the installer started. Switchify PC has been kept open.' + ); + }); + + it('explains invalid launcher responses', () => { + expect(updateInstallMessage('update_launcher_invalid_response')).toBe( + 'The update launcher returned an unexpected response. Switchify PC has been kept open.' + ); + }); }); describe('updateIndicatorState', () => { diff --git a/src/renderer/updates.ts b/src/renderer/updates.ts index 7e3da6a..5ebf06d 100644 --- a/src/renderer/updates.ts +++ b/src/renderer/updates.ts @@ -52,10 +52,16 @@ export function updateInstallMessage(reason: UpdateInstallFailureReason | null): return 'Updates are only supported on Windows.'; case 'installer_unavailable': return 'The downloaded installer could not be found. Download the update again.'; - case 'elevation_helper_unavailable': - return 'The update installer could not request permission to install. Reinstall Switchify PC from the latest installer.'; + case 'update_launcher_unavailable': + return 'The update launcher is missing. Reinstall Switchify PC from the latest installer.'; + case 'uac_cancelled': + return 'The update was cancelled before the installer could start.'; case 'installer_launch_failed': return 'The update installer could not be started. Download the update again or run the installer manually.'; + case 'installer_process_unavailable': + return 'Windows did not confirm that the installer started. Switchify PC has been kept open.'; + case 'update_launcher_invalid_response': + return 'The update launcher returned an unexpected response. Switchify PC has been kept open.'; } } diff --git a/src/shared/update.ts b/src/shared/update.ts index 4201333..c35b574 100644 --- a/src/shared/update.ts +++ b/src/shared/update.ts @@ -40,8 +40,11 @@ export type UpdateInstallFailureReason = | 'not_supported' | 'cancelled' | 'installer_unavailable' - | 'elevation_helper_unavailable' - | 'installer_launch_failed'; + | 'update_launcher_unavailable' + | 'uac_cancelled' + | 'installer_launch_failed' + | 'installer_process_unavailable' + | 'update_launcher_invalid_response'; export type UpdateInstallResult = { ok: true } | { ok: false; reason: UpdateInstallFailureReason };