diff --git a/src/main/index.ts b/src/main/index.ts index ede6b0496e1..5a4964bd729 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,7 @@ import os from 'node:os' import { app, BrowserWindow, + clipboard, dialog, ipcMain, nativeTheme, @@ -230,6 +231,11 @@ import { } from './startup/startup-diagnostics' import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { probeWindowsInstallDirAcl } from './startup/windows-install-dir-acl-probe' +import { + describeInstallDirAclPoison, + startWindowsInstallDirAclRepairIfPoisoned +} from './startup/windows-install-dir-acl-recovery' +import { presentRendererRecoveryPrompt } from './window/renderer-recovery-prompt' import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' import { registerServeSignalHandlers } from './startup/serve-signal-handlers' @@ -1539,7 +1545,15 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow }) // Why here: read-only, and the install DACL is the one thing a 0x80000003 // child death cannot tell us about itself. See electron/electron#51761. - probeWindowsInstallDirAcl({ isServeMode }) + probeWindowsInstallDirAcl({ + isServeMode, + onDone: (data) => + startWindowsInstallDirAclRepairIfPoisoned(data, { + isServeMode, + userDataPath: app.getPath('userData'), + appVersion: app.getVersion() + }) + }) } const window = createMainWindow(store, { @@ -1571,7 +1585,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow exitCode: details.exitCode ?? null, recentRecoveryCount }) - void presentRendererRecoveryPrompt(recentRecoveryCount) + void showRendererRecoveryPrompt(recentRecoveryCount) }, deferLoad: true, ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), @@ -1861,30 +1875,29 @@ function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { } // Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. -async function presentRendererRecoveryPrompt(recentRecoveryCount: number): Promise { - if (isQuitting) { - return - } - const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - const options = { - type: 'error' as const, - buttons: ['Reload', 'Quit'], - defaultId: 0, - cancelId: 1, - title: 'Orca keeps failing to load', - message: 'The app window crashed repeatedly and stopped reloading automatically.', - detail: `Orca tried to recover ${recentRecoveryCount} times in a row without success. This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.` - } - const { response } = window - ? await dialog.showMessageBox(window, options) - : await dialog.showMessageBox(options) - if (response === 0 && mainWindow && !mainWindow.isDestroyed()) { - recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') - loadMainWindow(mainWindow) - } else if (response === 1) { - isQuitting = true - app.quit() - } +async function showRendererRecoveryPrompt(recentRecoveryCount: number): Promise { + await presentRendererRecoveryPrompt({ + recentRecoveryCount, + isQuitting: () => isQuitting, + diagnose: describeInstallDirAclPoison, + showMessageBox: (options) => { + const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options) + }, + copyToClipboard: (text) => clipboard.writeText(text), + reload: () => { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') + // Why: leave the breaker open so a re-crash re-raises this prompt instead of resuming the auto-reload loop. + loadMainWindow(mainWindow) + }, + quit: () => { + isQuitting = true + app.quit() + } + }) } function getGpuFallbackEnvironment(): GpuFallbackEnvironment { diff --git a/src/main/startup/windows-install-dir-acl-probe.test.ts b/src/main/startup/windows-install-dir-acl-probe.test.ts index 367c42615a7..da63e35cd77 100644 --- a/src/main/startup/windows-install-dir-acl-probe.test.ts +++ b/src/main/startup/windows-install-dir-acl-probe.test.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from 'node:events' import { join } from 'node:path' import { describe, expect, it, beforeEach, vi } from 'vitest' import { @@ -7,55 +6,26 @@ import { WINDOWS_INSTALL_DIR_ACL_BREADCRUMB, type WindowsInstallDirAclProbeOptions } from './windows-install-dir-acl-probe' +import { + ALL_PACKAGES_ACE, + ENGLISH_BASELINE_ACES, + fakeIcaclsSpawn, + FRENCH_BASELINE_ACES, + FRENCH_RESTRICTED_PACKAGES_ACE, + icaclsDacl, + ORPHAN_PACKAGE_ACE, + RESTRICTED_PACKAGES_ACE +} from './windows-install-dir-acl.test-fixture' const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca' -const SYSTEM_ACES = [ - ' NT AUTHORITY\\SYSTEM:(I)(OI)(CI)(F)', - ' BUILTIN\\Administrators:(I)(OI)(CI)(F)', - ' awin\\neil:(I)(OI)(CI)(F)' -] +const ORPHAN = ORPHAN_PACKAGE_ACE +const RESTRICTED_GRANT = RESTRICTED_PACKAGES_ACE -/** Real icacls shape: the echoed path is glued onto the first principal. */ function dacl(target: string, firstAce: string, ...rest: string[]): string { - return [ - `${target} ${firstAce}`, - ...rest, - ...SYSTEM_ACES, - '', - 'Successfully processed 1 files' - ].join('\r\n') + return icaclsDacl(target, [firstAce, ...rest]) } -const ORPHAN = 'S-1-15-2-999-999-999:(OI)(CI)(RX)' -const RESTRICTED_GRANT = - 'APPLICATION PACKAGE AUTHORITY\\ALL RESTRICTED APPLICATION PACKAGES:(OI)(CI)(RX)' - -function fakeSpawn(output: (target: string) => string | null): { - spawnFn: WindowsInstallDirAclProbeOptions['spawnFn'] - calls: { file: string; args: string[] }[] -} { - const calls: { file: string; args: string[] }[] = [] - const spawnFn = ((file: string, args: string[]) => { - calls.push({ file, args }) - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter - kill: () => void - } - child.stdout = new EventEmitter() - child.kill = () => undefined - const out = output(args[0]) - setImmediate(() => { - if (out === null) { - child.emit('error', new Error('ENOENT')) - return - } - child.stdout.emit('data', Buffer.from(out, 'utf-8')) - child.emit('close', 0) - }) - return child - }) as unknown as WindowsInstallDirAclProbeOptions['spawnFn'] - return { spawnFn, calls } -} +const fakeSpawn = fakeIcaclsSpawn function probe(options: WindowsInstallDirAclProbeOptions): Promise> { return new Promise((resolve) => { @@ -84,9 +54,7 @@ describe('probeWindowsInstallDirAcl', () => { it('reports a clean DACL as unpoisoned', async () => { const data = await probe({ - spawnFn: fakeSpawn((target) => - [`${target} NT AUTHORITY\\SYSTEM:(I)(OI)(CI)(F)`, ...SYSTEM_ACES.slice(1)].join('\r\n') - ).spawnFn + spawnFn: fakeSpawn((target) => icaclsDacl(target, [], ENGLISH_BASELINE_ACES)).spawnFn }) expect(data.name).toBe(WINDOWS_INSTALL_DIR_ACL_BREADCRUMB) expect(data.status).toBe('ok') @@ -102,13 +70,27 @@ describe('probeWindowsInstallDirAcl', () => { expect(data.hasWellKnownPackageGrant).toBe(false) }) - it('clears the signature once a well-known package ACE grants access', async () => { + it('clears the signature once the restricted package ACE grants access', async () => { const data = await probeWith(RESTRICTED_GRANT, ORPHAN) expect(data.hasWellKnownPackageGrant).toBe(true) + expect(data.hasRestrictedPackageGrant).toBe(true) expect(data.orphanPackageSidCount).toBe(1) expect(data.matchesPoisonSignature).toBe(false) }) + // A Program Files install inherits ALL APPLICATION PACKAGES by default, and an + // orphan alongside it launched clean on win32 10.0.26200 / Electron 43.4.1 — so + // it is not the reproduced state, however useless -1 is to an LPAC token. + it.each([ + ['the localized-safe name form', ALL_PACKAGES_ACE], + ['the raw SID form', 'S-1-15-2-1:(OI)(CI)(RX)'] + ])('clears the signature when only ALL APPLICATION PACKAGES grants (%s)', async (_l, ace) => { + const data = await probeWith(ace, ORPHAN) + expect(data.hasWellKnownPackageGrant).toBe(true) + expect(data.hasRestrictedPackageGrant).toBe(false) + expect(data.matchesPoisonSignature).toBe(false) + }) + // The reproduced remedy was an additive *grant*; an ACE that grants nothing on // the object cannot satisfy the orphan, so it must not clear the signature. it.each([ @@ -119,6 +101,7 @@ describe('probeWindowsInstallDirAcl', () => { ])('does not let a %s well-known ACE satisfy an orphan', async (_label, ace) => { const data = await probeWith(ace, ORPHAN) expect(data.hasWellKnownPackageGrant).toBe(false) + expect(data.hasRestrictedPackageGrant).toBe(false) expect(data.matchesPoisonSignature).toBe(true) }) @@ -143,8 +126,10 @@ describe('probeWindowsInstallDirAcl', () => { platform: 'win32', installDir: INSTALL_DIR, fileExists: () => false, - spawnFn: fakeSpawn( - (target) => `${target} ${ORPHAN}\r\n AUTORITE NT\\Systeme:(I)(F)` + // fr-FR install that already carries the restricted grant: the name check + // cannot see it, so the signature is a false positive the flag must expose. + spawnFn: fakeSpawn((target) => + icaclsDacl(target, [ORPHAN, FRENCH_RESTRICTED_PACKAGES_ACE], FRENCH_BASELINE_ACES) ).spawnFn, recordBreadcrumb: (_name, d) => { resolve(d as Record) diff --git a/src/main/startup/windows-install-dir-acl-probe.ts b/src/main/startup/windows-install-dir-acl-probe.ts index e99f577dbbf..42db3ee3d10 100644 --- a/src/main/startup/windows-install-dir-acl-probe.ts +++ b/src/main/startup/windows-install-dir-acl-probe.ts @@ -18,6 +18,12 @@ import { getIcaclsExePath } from '../win32-utils' * S-1-15-2-2 clears it — see electron/electron#51761. This records whether a * machine is in that state so the next crash report answers the question itself. * + * The verdict deliberately accepts EITHER well-known grant: a tree carrying the + * orphan plus S-1-15-2-1 only (the Program Files default) launched clean on + * win32 10.0.26200 / Electron 43.4.1, so it is not the reproduced state and must + * not be treated as one. `hasRestrictedPackageGrant` is still reported, so a + * report can tell the two shapes apart if that ever stops holding. + * * Diagnostic only: it never writes an ACL and never changes behavior. */ @@ -32,13 +38,18 @@ const PROBE_BUDGET_MS = 5_000 /** Raw S-1-15-2-* means icacls could not resolve it — locale-independent. */ const RAW_PACKAGE_SID = /\bS-1-15-2-[0-9-]+\b/i -const WELL_KNOWN_PACKAGE_SIDS = new Set(['s-1-15-2-1', 's-1-15-2-2']) +/** ALL RESTRICTED APPLICATION PACKAGES: the grant the reproduced remedy added. */ +const RESTRICTED_PACKAGES_SID = 's-1-15-2-2' +const WELL_KNOWN_PACKAGE_SIDS = new Set(['s-1-15-2-1', RESTRICTED_PACKAGES_SID]) // icacls localizes these; the raw SID form is never printed for them. Orphan // detection stays SID-form and locale-independent, but this check is not — so we // report whether the output looked English at all, making a locale-induced // false positive recognizable instead of silent. const WELL_KNOWN_PACKAGE_NAMES = /ALL (RESTRICTED )?APPLICATION PACKAGES/i -const ENGLISH_PRINCIPAL = /\b(NT AUTHORITY|BUILTIN|APPLICATION PACKAGE AUTHORITY)\b/i +const RESTRICTED_PACKAGE_NAME = /ALL RESTRICTED APPLICATION PACKAGES/i +// Not BUILTIN: fr-FR and es-ES print it verbatim while localizing the package +// names, so it is evidence of nothing. SYSTEM's ACE is on every install tree. +const ENGLISH_PRINCIPAL = /\b(NT AUTHORITY|APPLICATION PACKAGE AUTHORITY)\b/i // Why: an ACE that denies, or only propagates to children, grants nothing on this // object — so it cannot satisfy an orphan the way the reproduced fix did. const NON_GRANTING_FLAGS = /\((?:DENY|IO)\)/i @@ -57,6 +68,7 @@ export type WindowsInstallDirAclProbeOptions = { type AclFacts = { orphanPackageSids: string[] hasWellKnownPackageGrant: boolean + hasRestrictedPackageGrant: boolean sawEnglishPrincipal: boolean } @@ -96,6 +108,7 @@ function readDacl(spawnFn: typeof spawn, target: string, deadlineMs: number): Pr function collectAclFacts(daclOutput: string): AclFacts { const orphanPackageSids: string[] = [] let hasWellKnownPackageGrant = false + let hasRestrictedPackageGrant = false let sawEnglishPrincipal = false for (const line of daclOutput.split(/\r?\n/)) { if (ENGLISH_PRINCIPAL.test(line)) { @@ -108,17 +121,28 @@ function collectAclFacts(daclOutput: string): AclFacts { continue } const [, principal, flags] = ace - const sid = RAW_PACKAGE_SID.exec(principal)?.[0] - if (sid && !WELL_KNOWN_PACKAGE_SIDS.has(sid.toLowerCase())) { - orphanPackageSids.push(sid) + const rawSid = RAW_PACKAGE_SID.exec(principal)?.[0] + const sid = rawSid?.toLowerCase() + if (rawSid && !WELL_KNOWN_PACKAGE_SIDS.has(rawSid.toLowerCase())) { + orphanPackageSids.push(rawSid) continue } const isWellKnown = sid !== undefined || WELL_KNOWN_PACKAGE_NAMES.test(principal) - if (isWellKnown && !NON_GRANTING_FLAGS.test(flags)) { - hasWellKnownPackageGrant = true + if (!isWellKnown || NON_GRANTING_FLAGS.test(flags)) { + continue + } + hasWellKnownPackageGrant = true + // Reported, never the verdict: narrows which grant is present for triage. + if (sid === RESTRICTED_PACKAGES_SID || (!sid && RESTRICTED_PACKAGE_NAME.test(principal))) { + hasRestrictedPackageGrant = true } } - return { orphanPackageSids, hasWellKnownPackageGrant, sawEnglishPrincipal } + return { + orphanPackageSids, + hasWellKnownPackageGrant, + hasRestrictedPackageGrant, + sawEnglishPrincipal + } } function resolveTargets(installDir: string, fileExists: (path: string) => boolean): string[] { @@ -143,6 +167,7 @@ async function runProbe(options: WindowsInstallDirAclProbeOptions): Promise f.orphanPackageSids))] const hasWellKnownPackageGrant = facts.some((f) => f.hasWellKnownPackageGrant) + const hasRestrictedPackageGrant = facts.some((f) => f.hasRestrictedPackageGrant) // Why per target: a grant on the directory does not grant on the module file, // and the reproduced failure is a per-file content read. Merging would let a // grant on one target mask its absence on the other. @@ -158,7 +183,10 @@ async function runProbe(options: WindowsInstallDirAclProbeOptions): Promise f.sawEnglishPrincipal), diff --git a/src/main/startup/windows-install-dir-acl-recovery.test.ts b/src/main/startup/windows-install-dir-acl-recovery.test.ts new file mode 100644 index 00000000000..2b5d00ff40a --- /dev/null +++ b/src/main/startup/windows-install-dir-acl-recovery.test.ts @@ -0,0 +1,187 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' +import { + probeWindowsInstallDirAcl, + resetWindowsInstallDirAclProbeForTest +} from './windows-install-dir-acl-probe' +import { + describeInstallDirAclPoison, + resetWindowsInstallDirAclRecoveryForTest, + startWindowsInstallDirAclRepairIfPoisoned +} from './windows-install-dir-acl-recovery' +import { resetWindowsInstallDirAclRepairForTest } from './windows-install-dir-package-acl-repair' +import { + ALL_PACKAGES_ACE, + fakeIcaclsSpawn, + FRENCH_BASELINE_ACES, + FRENCH_RESTRICTED_PACKAGES_ACE, + icaclsDacl, + ORPHAN_PACKAGE_ACE, + RESTRICTED_PACKAGES_ACE +} from './windows-install-dir-acl.test-fixture' + +const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca' +const APP_VERSION = '1.4.184' + +/** + * Drives the production path: the real probe hands its verdict to the real gate, + * which decides whether icacls ever runs. Only the two process seams are faked. + */ +function probeThenRecover( + dacl: (target: string) => string, + options: { failRepair?: boolean } = {} +): Promise { + const specs: ProcessSpec[] = [] + const runProcessFn = async (spec: ProcessSpec): Promise => { + specs.push(spec) + return { + code: options.failRepair === true ? 5 : 0, + signal: null, + stdout: 'Successfully processed 81 files; Failed processing 0 files', + stderr: '', + timedOut: false + } + } + return new Promise((resolve) => { + probeWindowsInstallDirAcl({ + platform: 'win32', + installDir: INSTALL_DIR, + fileExists: (path) => path.endsWith('ffmpeg.dll'), + spawnFn: fakeIcaclsSpawn(dacl).spawnFn, + recordBreadcrumb: () => undefined, + onDone: (data) => { + startWindowsInstallDirAclRepairIfPoisoned(data, { + platform: 'win32', + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + userDataPath: mkdtempSync(join(tmpdir(), 'orca-acl-recovery-')), + runProcessFn: runProcessFn as never, + recordBreadcrumb: () => undefined + }) + // Longer than the repair's own setImmediate hop, so a repair that was + // started has always spawned by the time this resolves. + setTimeout(() => resolve(specs), 25) + } + }) + }) +} + +describe('startWindowsInstallDirAclRepairIfPoisoned', () => { + beforeEach(() => { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + }) + + it('repairs when the probe sees an orphan package ACE and no well-known grant', async () => { + const specs = await probeThenRecover((target) => icaclsDacl(target, [ORPHAN_PACKAGE_ACE])) + expect(specs.map((spec) => spec.args?.[2])).toEqual([ + '*S-1-15-2-2:(OI)(CI)(RX)', + '*S-1-15-2-2:(RX)' + ]) + }) + + // Orphan + the Program Files ALL APPLICATION PACKAGES default launched clean on + // win32 10.0.26200 / Electron 43.4.1, so it earns neither an ACL write nor the + // accusing dialog copy. + it('leaves an install whose package grant is ALL APPLICATION PACKAGES alone', async () => { + const specs = await probeThenRecover((target) => + icaclsDacl(target, [ORPHAN_PACKAGE_ACE, ALL_PACKAGES_ACE]) + ) + expect(specs).toHaveLength(0) + expect(describeInstallDirAclPoison()).toBeNull() + }) + + it('does not touch an install that already carries the restricted grant', async () => { + const specs = await probeThenRecover((target) => + icaclsDacl(target, [ORPHAN_PACKAGE_ACE, RESTRICTED_PACKAGES_ACE]) + ) + expect(specs).toHaveLength(0) + expect(describeInstallDirAclPoison()).toBeNull() + }) + + // A localized icacls prints the grant under a name the probe cannot match, so + // the signature is unproven: neither icacls nor the accusing dialog copy. + it('does not act on a signature from a non-English icacls', async () => { + const specs = await probeThenRecover((target) => + icaclsDacl(target, [ORPHAN_PACKAGE_ACE, FRENCH_RESTRICTED_PACKAGES_ACE], FRENCH_BASELINE_ACES) + ) + expect(specs).toHaveLength(0) + expect(describeInstallDirAclPoison()).toBeNull() + }) + + it('does not act when the probe could not read the DACL', async () => { + const specs = await new Promise((resolve) => { + const collected: ProcessSpec[] = [] + probeWindowsInstallDirAcl({ + platform: 'win32', + installDir: INSTALL_DIR, + fileExists: () => false, + spawnFn: fakeIcaclsSpawn(() => null).spawnFn, + recordBreadcrumb: () => undefined, + onDone: (data) => { + startWindowsInstallDirAclRepairIfPoisoned(data, { + platform: 'win32', + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + userDataPath: mkdtempSync(join(tmpdir(), 'orca-acl-recovery-')), + runProcessFn: (async (spec: ProcessSpec) => { + collected.push(spec) + throw new Error('unreachable') + }) as never, + recordBreadcrumb: () => undefined + }) + setTimeout(() => resolve(collected), 25) + } + }) + }) + expect(specs).toHaveLength(0) + expect(describeInstallDirAclPoison()).toBeNull() + }) +}) + +describe('describeInstallDirAclPoison', () => { + beforeEach(() => { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + }) + + it('offers the copyable commands, and drops them once the repair lands', async () => { + await probeThenRecover((target) => icaclsDacl(target, [ORPHAN_PACKAGE_ACE])) + const repaired = describeInstallDirAclPoison() + expect(repaired?.detail).toContain('Orca repaired the permissions') + expect(repaired?.detail).not.toContain('Administrator Command Prompt') + expect(repaired?.commands).toEqual([ + `icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(OI)(CI)(RX)"`, + `icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(RX)" /T /C` + ]) + }) + + it('walks a standard user through icacls when the repair could not write', async () => { + await probeThenRecover((target) => icaclsDacl(target, [ORPHAN_PACKAGE_ACE]), { + failRepair: true + }) + const failed = describeInstallDirAclPoison() + expect(failed?.detail).toContain('needs an administrator') + expect(failed?.detail).toContain(`icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(RX)" /T /C`) + }) + + it('reports the repair as in flight before icacls has answered', () => { + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: true, wellKnownNameCheckReliable: true }, + { + platform: 'win32', + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + userDataPath: mkdtempSync(join(tmpdir(), 'orca-acl-recovery-')), + runProcessFn: (() => new Promise(() => undefined)) as never, + recordBreadcrumb: () => undefined + } + ) + expect(describeInstallDirAclPoison()?.detail).toContain('repairing the permissions now') + }) +}) diff --git a/src/main/startup/windows-install-dir-acl-recovery.ts b/src/main/startup/windows-install-dir-acl-recovery.ts new file mode 100644 index 00000000000..0aa6870192e --- /dev/null +++ b/src/main/startup/windows-install-dir-acl-recovery.ts @@ -0,0 +1,77 @@ +import { dirname } from 'node:path' +import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' +import { logStartupMilestone } from './startup-diagnostics' +import { + buildInstallDirAclRepairCommands, + isInstallDirAclPoisonVerdict, + repairWindowsInstallDirPackageAcl, + type WindowsInstallDirAclRepairArgs, + type WindowsInstallDirAclRepairResult +} from './windows-install-dir-package-acl-repair' + +/** + * Joins the read-only install-DACL probe to the repair, and keeps the verdict so + * the renderer-recovery dialog can say what is actually wrong instead of blaming + * the graphics driver. See `windows-install-dir-package-acl-repair.ts`. + */ + +export type InstallDirAclPoisonDiagnosis = { + /** Dialog copy; ends with the commands when the user has to run them. */ + detail: string + commands: string[] +} + +export type WindowsInstallDirAclRecoveryOptions = Omit + +type RepairStage = WindowsInstallDirAclRepairResult['mode'] | 'pending' + +let poison: { installDir: string; stage: RepairStage } | null = null + +export function resetWindowsInstallDirAclRecoveryForTest(): void { + poison = null +} + +/** The probe's `onDone`: no-op unless the machine is in the reproduced state. */ +export function startWindowsInstallDirAclRepairIfPoisoned( + data: CrashReportBreadcrumbData, + options: WindowsInstallDirAclRecoveryOptions +): void { + if (!isInstallDirAclPoisonVerdict(data)) { + return + } + const installDir = options.installDir ?? dirname(process.execPath) + poison = { installDir, stage: 'pending' } + repairWindowsInstallDirPackageAcl({ + ...options, + installDir, + onDone: (result) => { + poison = { installDir, stage: result.mode } + logStartupMilestone('install-dir-acl-repair-done', { mode: result.mode }) + if (result.mode === 'failed') { + console.warn('[win32-acl] install dir package ACL repair failed:', result.reason) + } + } + }) +} + +const CAUSE = + "Windows permissions on Orca's install folder are blocking its own sandboxed processes from reading the files it shipped with." + +// Why the exact commands: the window is blank, so the dialog is the only place a user can be told what to run. +export function describeInstallDirAclPoison(): InstallDirAclPoisonDiagnosis | null { + if (!poison) { + return null + } + const commands = buildInstallDirAclRepairCommands(poison.installDir) + if (poison.stage === 'repaired') { + return { detail: `${CAUSE}\n\nOrca repaired the permissions. Reload to use them.`, commands } + } + const status = + poison.stage === 'pending' + ? 'Orca is repairing the permissions now.' + : 'Orca could not repair them, which usually means the folder needs an administrator.' + return { + detail: `${CAUSE} ${status}\n\nRun these in an Administrator Command Prompt, then relaunch Orca:\n\n${commands.join('\n')}`, + commands + } +} diff --git a/src/main/startup/windows-install-dir-acl.test-fixture.ts b/src/main/startup/windows-install-dir-acl.test-fixture.ts new file mode 100644 index 00000000000..ecbfec808f0 --- /dev/null +++ b/src/main/startup/windows-install-dir-acl.test-fixture.ts @@ -0,0 +1,66 @@ +import { EventEmitter } from 'node:events' +import type { spawn } from 'node:child_process' + +/** Shared icacls doubles for the install-dir DACL probe, repair, and recovery tests. */ + +export const ORPHAN_PACKAGE_ACE = 'S-1-15-2-999-999-999:(OI)(CI)(RX)' +export const RESTRICTED_PACKAGES_ACE = + 'APPLICATION PACKAGE AUTHORITY\\ALL RESTRICTED APPLICATION PACKAGES:(OI)(CI)(RX)' +/** The Program Files default: present on healthy installs, which launch clean. */ +export const ALL_PACKAGES_ACE = 'APPLICATION PACKAGE AUTHORITY\\ALL APPLICATION PACKAGES:(RX)' + +export const ENGLISH_BASELINE_ACES = [ + 'NT AUTHORITY\\SYSTEM:(I)(OI)(CI)(F)', + 'BUILTIN\\Administrators:(I)(OI)(CI)(F)', + 'awin\\neil:(I)(OI)(CI)(F)' +] +/** fr-FR icacls: no principal the English name check can recognize. */ +export const FRENCH_BASELINE_ACES = [ + 'AUTORITE NT\\Systeme:(I)(OI)(CI)(F)', + 'BUILTIN\\Administrateurs:(I)(OI)(CI)(F)' +] +export const FRENCH_RESTRICTED_PACKAGES_ACE = + "AUTORITE DE PACKAGE D'APPLICATION\\TOUS LES PACKAGES D'APPLICATION RESTREINTS:(RX)" + +/** Real icacls shape: the echoed path is glued onto the first principal. */ +export function icaclsDacl( + target: string, + aces: string[], + baseline: string[] = ENGLISH_BASELINE_ACES +): string { + const [first, ...rest] = [...aces, ...baseline] + return [ + `${target} ${first}`, + ...rest.map((ace) => ` ${ace}`), + '', + 'Successfully processed 1 files' + ].join('\r\n') +} + +/** `null` output makes the spawn fail, as an unreadable target does. */ +export function fakeIcaclsSpawn(output: (target: string) => string | null): { + spawnFn: typeof spawn + calls: { file: string; args: string[] }[] +} { + const calls: { file: string; args: string[] }[] = [] + const spawnFn = ((file: string, args: string[]) => { + calls.push({ file, args }) + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + kill: () => void + } + child.stdout = new EventEmitter() + child.kill = () => undefined + const out = output(args[0]) + setImmediate(() => { + if (out === null) { + child.emit('error', new Error('ENOENT')) + return + } + child.stdout.emit('data', Buffer.from(out, 'utf-8')) + child.emit('close', 0) + }) + return child + }) as unknown as typeof spawn + return { spawnFn, calls } +} diff --git a/src/main/startup/windows-install-dir-package-acl-repair.test.ts b/src/main/startup/windows-install-dir-package-acl-repair.test.ts new file mode 100644 index 00000000000..65d11e9fcde --- /dev/null +++ b/src/main/startup/windows-install-dir-package-acl-repair.test.ts @@ -0,0 +1,303 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' +import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' +import { + buildInstallDirAclRepairCommands, + isInstallDirAclPoisonVerdict, + repairWindowsInstallDirPackageAcl, + resetWindowsInstallDirAclRepairForTest, + WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB, + WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE, + WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION, + type WindowsInstallDirAclRepairResult +} from './windows-install-dir-package-acl-repair' + +const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca' +const APP_VERSION = '1.4.184' + +/** icacls' real success summary; the /T pass prints one per tree. */ +function icaclsOutput(processed: number, failed: number): string { + return `Successfully processed ${processed} files; Failed processing ${failed} files` +} + +type Runner = (spec: ProcessSpec) => Promise + +function fakeRunner(reply: (spec: ProcessSpec) => Partial = () => ({})): { + run: Runner + specs: ProcessSpec[] +} { + const specs: ProcessSpec[] = [] + const run: Runner = async (spec) => { + specs.push(spec) + return { + code: 0, + signal: null, + stdout: icaclsOutput(81, 0), + stderr: '', + timedOut: false, + ...reply(spec) + } + } + return { run, specs } +} + +function userDataDir(): string { + return mkdtempSync(join(tmpdir(), 'orca-acl-repair-')) +} + +function repair( + overrides: { + userDataPath?: string + appVersion?: string + installDir?: string + platform?: NodeJS.Platform + isServeMode?: boolean + run?: Runner + } = {} +): Promise<{ + result: WindowsInstallDirAclRepairResult + data: CrashReportBreadcrumbData +}> { + const { run, ...rest } = overrides + return new Promise((resolve, reject) => { + let data: CrashReportBreadcrumbData = {} + repairWindowsInstallDirPackageAcl({ + platform: 'win32', + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + ...rest, + userDataPath: rest.userDataPath ?? userDataDir(), + runProcessFn: (run ?? fakeRunner().run) as never, + recordBreadcrumb: (name, breadcrumb) => { + expect(name).toBe(WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB) + data = breadcrumb ?? {} + return undefined + }, + onDone: (result) => resolve({ result, data }) + }) + setTimeout(() => reject(new Error('repair never settled')), 2_000).unref?.() + }) +} + +describe('repairWindowsInstallDirPackageAcl', () => { + beforeEach(() => { + resetWindowsInstallDirAclRepairForTest() + }) + + it('grants the flagless (RX) on the recursive pass, never (OI)(CI)(RX)', async () => { + // The whole fix: icacls silently no-ops an inheritance-flagged grant against a + // FILE — it exits 0 and reports zero failures while writing no ACE, so the + // shipped modules stay unreadable and every renderer still dies at init. + const { run, specs } = fakeRunner() + await repair({ run }) + + const treePass = specs.find((spec) => spec.args?.includes('/T')) + expect(treePass?.args).toEqual([INSTALL_DIR, '/grant', '*S-1-15-2-2:(RX)', '/T', '/C']) + expect(treePass?.args).not.toContain('*S-1-15-2-2:(OI)(CI)(RX)') + }) + + it('grants the inheritable (OI)(CI)(RX) on the root, without /T', async () => { + const { run, specs } = fakeRunner() + await repair({ run }) + + const rootPass = specs.find((spec) => !spec.args?.includes('/T')) + expect(rootPass?.args).toEqual([INSTALL_DIR, '/grant', '*S-1-15-2-2:(OI)(CI)(RX)']) + expect(specs).toHaveLength(2) + }) + + it('is additive on every pass so SYSTEM, Administrators and the orphan survive', async () => { + const { run, specs } = fakeRunner() + await repair({ run }) + + for (const spec of specs) { + expect(spec.args).toContain('/grant') + expect(spec.args).not.toContain('/grant:r') + } + }) + + it('reports the repair and records an ok breadcrumb', async () => { + const { result, data } = await repair() + expect(result).toEqual({ mode: 'repaired' }) + expect(data.status).toBe('ok') + expect(data.failedFileCount).toBe(0) + }) + + it('runs once and then never spawns again for the same install and version', async () => { + const userDataPath = userDataDir() + const first = fakeRunner() + await repair({ userDataPath, run: first.run }) + expect(first.specs).toHaveLength(2) + + resetWindowsInstallDirAclRepairForTest() + const second = fakeRunner() + const { result, data } = await repair({ userDataPath, run: second.run }) + expect(second.specs).toHaveLength(0) + expect(result).toEqual({ mode: 'marker-hit' }) + expect(data.reason).toBe('marker-hit') + }) + + it('re-runs after an update and after a reinstall to another directory', async () => { + const userDataPath = userDataDir() + await repair({ userDataPath }) + + resetWindowsInstallDirAclRepairForTest() + const updated = fakeRunner() + expect( + (await repair({ userDataPath, appVersion: '1.4.185', run: updated.run })).result + ).toEqual({ mode: 'repaired' }) + expect(updated.specs).toHaveLength(2) + + resetWindowsInstallDirAclRepairForTest() + const moved = fakeRunner() + expect( + ( + await repair({ + userDataPath, + installDir: 'D:\\Program Files\\Orca', + run: moved.run + }) + ).result + ).toEqual({ mode: 'repaired' }) + expect(moved.specs).toHaveLength(2) + }) + + it('re-runs when the marker is from an older scheme or is corrupt', async () => { + const userDataPath = userDataDir() + const markerFile = join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE) + writeFileSync( + markerFile, + JSON.stringify({ + schemeVersion: WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION - 1, + installDir: INSTALL_DIR, + appVersion: APP_VERSION + }) + ) + const stale = fakeRunner() + await repair({ userDataPath, run: stale.run }) + expect(stale.specs).toHaveLength(2) + + resetWindowsInstallDirAclRepairForTest() + writeFileSync(markerFile, '{ not json') + const corrupt = fakeRunner() + await repair({ userDataPath, run: corrupt.run }) + expect(corrupt.specs).toHaveLength(2) + }) + + it('records the failed-file count when a standard user cannot write the ACL', async () => { + // Program Files: /C keeps going, icacls reports the losses and exits non-zero. + const { run, specs } = fakeRunner((spec) => + spec.args?.includes('/T') + ? { + code: 1332, + stdout: icaclsOutput(0, 81), + stderr: 'Access is denied.' + } + : { code: 5, stdout: '', stderr: 'Access is denied.' } + ) + const { result, data } = await repair({ run }) + + expect(result.mode).toBe('failed') + expect(data.status).toBe('failed') + expect(data.failedFileCount).toBe(81) + // No retry loop: exactly the two passes, then it gives up for this version. + expect(specs).toHaveLength(2) + }) + + it('still runs the per-file pass when the root grant fails', async () => { + const { run, specs } = fakeRunner((spec) => + spec.args?.includes('/T') ? {} : { code: 5, stderr: 'Access is denied.' } + ) + const { result } = await repair({ run }) + expect(specs).toHaveLength(2) + expect(result.mode).toBe('failed') + }) + + it('does not throw when the runner rejects, and still records a breadcrumb', async () => { + const run: Runner = () => Promise.reject(new Error('spawn EPERM')) + const { result, data } = await repair({ run }) + expect(result.mode).toBe('failed') + expect(data.status).toBe('failed') + expect(String(data.reason)).toContain('spawn EPERM') + }) + + it('marks the attempt so a hopeless install does not re-spawn icacls every launch', async () => { + const userDataPath = userDataDir() + await repair({ userDataPath, run: fakeRunner(() => ({ code: 5 })).run }) + + const marker = JSON.parse( + readFileSync(join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE), 'utf-8') + ) as { outcome: string } + expect(marker.outcome).toBe('failed') + }) + + it('is a no-op off win32 and in serve mode', async () => { + const off = fakeRunner() + repairWindowsInstallDirPackageAcl({ + platform: 'darwin', + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + userDataPath: userDataDir(), + runProcessFn: off.run as never, + recordBreadcrumb: vi.fn(), + onDone: () => expect.unreachable('no-op must not settle') + }) + resetWindowsInstallDirAclRepairForTest() + const serve = fakeRunner() + const recordServe = vi.fn() + repairWindowsInstallDirPackageAcl({ + platform: 'win32', + isServeMode: true, + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + userDataPath: userDataDir(), + runProcessFn: serve.run as never, + recordBreadcrumb: recordServe, + onDone: () => expect.unreachable('no-op must not settle') + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(off.specs).toHaveLength(0) + expect(serve.specs).toHaveLength(0) + expect(recordServe).not.toHaveBeenCalled() + }) + + it('offers commands that match what the repair itself runs', () => { + expect(buildInstallDirAclRepairCommands(INSTALL_DIR)).toEqual([ + `icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(OI)(CI)(RX)"`, + `icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(RX)" /T /C` + ]) + }) +}) + +describe('isInstallDirAclPoisonVerdict', () => { + it('only accepts the probe verdict that reproduced the crash', () => { + expect( + isInstallDirAclPoisonVerdict({ + status: 'ok', + matchesPoisonSignature: true, + wellKnownNameCheckReliable: true + }) + ).toBe(true) + expect( + isInstallDirAclPoisonVerdict({ + status: 'ok', + matchesPoisonSignature: false + }) + ).toBe(false) + expect(isInstallDirAclPoisonVerdict({ status: 'failed', reason: 'unreadable' })).toBe(false) + }) + + // A localized icacls hides the well-known grants behind translated names, so the + // signature there is unproven: repairing and blaming the install would be wrong. + it('refuses a signature the probe could not name-check', () => { + expect( + isInstallDirAclPoisonVerdict({ + status: 'ok', + matchesPoisonSignature: true, + wellKnownNameCheckReliable: false + }) + ).toBe(false) + }) +}) diff --git a/src/main/startup/windows-install-dir-package-acl-repair.ts b/src/main/startup/windows-install-dir-package-acl-repair.ts new file mode 100644 index 00000000000..606edae417c --- /dev/null +++ b/src/main/startup/windows-install-dir-package-acl-repair.ts @@ -0,0 +1,276 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { + sanitizeCrashReportString, + type CrashReportBreadcrumbData +} from '../../shared/crash-reporting' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { getIcaclsExePath } from '../win32-utils' + +/** + * Self-repair for the install-directory package ACL that + * `windows-install-dir-acl-probe.ts` detects (electron/electron#51761). + * + * When the install tree carries an orphan AppContainer ACE (S-1-15-2-) and no + * well-known package grant at all, Chromium's LPAC children are denied + * read on the shipped modules and die at init with 0x80000003. Reproduced on + * win32 10.0.26200 / Electron 43.4.1: the GPU child dies six times and the + * browser FATALs, or — once the GPU fallback engages `--in-process-gpu` — the + * renderer dies on every load and the window stays blank forever. + * + * Two additive grants clear it. Both are required: + * - the root grant is inheritable so files written later (an update) inherit it; + * - the /T pass must use the FLAGLESS `(RX)`. `icacls /grant + * "*S-1-15-2-2:(OI)(CI)(RX)"` exits 0 and reports "Failed processing 0 files" + * while writing no ACE at all — a silent no-op. A directory grant alone does + * not fix a shipped module that carries its own explicit DACL, so a /T pass + * that silently no-ops leaves the app just as dead as before. + * + * Never `/grant:r`: the install tree's SYSTEM/Administrators/user ACEs must + * survive, and so must the orphan (removing an ACE is not ours to do). + */ + +export const WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB = 'windows_install_dir_acl_repair' +export const WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE = 'windows-install-dir-acl-repair.json' +export const WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION = 1 + +/** `*`-prefixed so icacls reads it as a SID on every locale. */ +const RESTRICTED_APP_PACKAGES_SID = '*S-1-15-2-2' +/** Root only: inheritable, so files added by a later update inherit the grant. */ +export const INSTALL_DIR_ROOT_GRANT = `${RESTRICTED_APP_PACKAGES_SID}:(OI)(CI)(RX)` +/** Existing entries: flagless, the only form icacls actually writes onto a file. */ +export const INSTALL_DIR_TREE_GRANT = `${RESTRICTED_APP_PACKAGES_SID}:(RX)` + +const ROOT_GRANT_TIMEOUT_MS = 15_000 +// A packaged Orca install tree is ~3.2k entries (a bare Electron dist is ~80, which +// is NOT the shape that ships: app.asar.unpacked and node_modules dominate). At the +// ~2.2ms/entry this repo measured for a /T walk in windows-user-data-acl.ts, that is +// ~5-7s of per-file DACL writes, each intercepted by Defender's filter driver. The +// cap covers a slow or contended volume on top of that, not a tree this size alone. +const TREE_GRANT_TIMEOUT_MS = 120_000 + +/** icacls localizes this; an unparsed summary reports the count as unknown. */ +const FAILED_PROCESSING = /Failed processing (\d+) files?/i + +export type WindowsInstallDirAclRepairResult = + | { mode: 'marker-hit' } + | { mode: 'repaired' } + | { mode: 'failed'; reason: string; failedFileCount: number | null } + +export type WindowsInstallDirAclRepairOptions = { + installDir?: string + platform?: NodeJS.Platform + isServeMode?: boolean + /** Test seams. */ + runProcessFn?: typeof runProcess + recordBreadcrumb?: typeof recordDurableCrashBreadcrumb + onDone?: (result: WindowsInstallDirAclRepairResult) => void +} + +export type WindowsInstallDirAclRepairArgs = WindowsInstallDirAclRepairOptions & { + /** Marker home; the install dir itself is exactly what we may not be able to write. */ + userDataPath: string + /** Part of the marker key so an update re-runs against the new files. */ + appVersion: string +} + +type RepairMarker = { + schemeVersion: number + installDir: string + appVersion: string + attemptedAt: number + outcome: string +} + +/** + * The probe's verdict is the only trigger: an orphan package ACE with no + * well-known package grant to satisfy it. A localized icacls prints those grants + * under translated names the probe cannot match, so an unreliable name check is + * not evidence of poison — acting on it would spawn icacls and tell a user with a + * healthy install that their permissions are broken. + */ +export function isInstallDirAclPoisonVerdict(data: CrashReportBreadcrumbData): boolean { + return data.matchesPoisonSignature === true && data.wellKnownNameCheckReliable !== false +} + +/** The commands to hand a user whose account cannot write the install ACL. */ +export function buildInstallDirAclRepairCommands(installDir: string): string[] { + return [ + `icacls "${installDir}" /grant "${INSTALL_DIR_ROOT_GRANT}"`, + `icacls "${installDir}" /grant "${INSTALL_DIR_TREE_GRANT}" /T /C` + ] +} + +function markerPath(userDataPath: string): string { + return join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE) +} + +function hasMarkerFor(args: WindowsInstallDirAclRepairArgs): boolean { + try { + const parsed = JSON.parse(readFileSync(markerPath(args.userDataPath), 'utf-8')) as + | Partial + | undefined + return ( + parsed?.schemeVersion === WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION && + parsed.installDir === args.installDir && + parsed.appVersion === args.appVersion + ) + } catch { + return false // missing or corrupt -> attempt again + } +} + +// Why write it on failure too: a standard-user Program Files install can never +// win, and re-spawning icacls on every launch forever buys nothing. Reinstall or +// update changes the key and retries. +function writeMarker(args: WindowsInstallDirAclRepairArgs, outcome: string): void { + const marker: RepairMarker = { + schemeVersion: WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION, + installDir: args.installDir ?? '', + appVersion: args.appVersion, + attemptedAt: Date.now(), + outcome + } + if (!existsSync(args.userDataPath)) { + mkdirSync(args.userDataPath, { recursive: true }) + } + writeFileSync(markerPath(args.userDataPath), JSON.stringify(marker)) +} + +type GrantOutcome = { + ok: boolean + failedFileCount: number | null + reason?: string +} + +async function runGrant( + runner: typeof runProcess, + installDir: string, + grant: string, + extraArgs: readonly string[], + timeoutMs: number +): Promise { + try { + const result = await runner({ + program: getIcaclsExePath(), + args: [installDir, '/grant', grant, ...extraArgs], + timeoutMs + }) + const summary = FAILED_PROCESSING.exec(`${result.stdout}\n${result.stderr}`) + const failedFileCount = summary ? Number(summary[1]) : null + if (result.timedOut) { + return { ok: false, failedFileCount, reason: 'timeout' } + } + if (result.code !== 0) { + return { ok: false, failedFileCount, reason: `exit ${result.code}` } + } + if (failedFileCount !== null && failedFileCount > 0) { + return { ok: false, failedFileCount, reason: 'failed-files' } + } + return { ok: true, failedFileCount } + } catch (error) { + return { + ok: false, + failedFileCount: null, + reason: sanitizeCrashReportString(String(error), 200) + } + } +} + +async function runRepair(args: WindowsInstallDirAclRepairArgs): Promise { + const record = args.recordBreadcrumb ?? recordDurableCrashBreadcrumb + const installDir = args.installDir ?? dirname(process.execPath) + const resolved: WindowsInstallDirAclRepairArgs = { ...args, installDir } + let result: WindowsInstallDirAclRepairResult + let data: CrashReportBreadcrumbData + try { + if (hasMarkerFor(resolved)) { + result = { mode: 'marker-hit' } + data = { status: 'skipped', reason: 'marker-hit' } + } else { + const runner = args.runProcessFn ?? runProcess + const root = await runGrant( + runner, + installDir, + INSTALL_DIR_ROOT_GRANT, + [], + ROOT_GRANT_TIMEOUT_MS + ) + // Unconditional: the root grant failing does not make the per-file pass — + // the one that actually unblocks the shipped modules — any less worth trying. + const tree = await runGrant( + runner, + installDir, + INSTALL_DIR_TREE_GRANT, + ['/T', '/C'], + TREE_GRANT_TIMEOUT_MS + ) + const failedFileCount = tree.failedFileCount ?? root.failedFileCount + if (root.ok && tree.ok) { + result = { mode: 'repaired' } + data = { status: 'ok', failedFileCount: failedFileCount ?? -1 } + } else { + const reason = [ + root.reason && `root: ${root.reason}`, + tree.reason && `tree: ${tree.reason}` + ] + .filter(Boolean) + .join('; ') + result = { mode: 'failed', reason, failedFileCount } + data = { + status: 'failed', + reason: sanitizeCrashReportString(reason, 200), + // -1 means icacls printed no parsable summary (a localized Windows). + failedFileCount: failedFileCount ?? -1 + } + } + try { + writeMarker(resolved, result.mode) + } catch (error) { + data = { + ...data, + markerWriteFailed: sanitizeCrashReportString(String(error), 200) + } + } + } + } catch (error) { + result = { mode: 'failed', reason: String(error), failedFileCount: null } + data = { + status: 'failed', + reason: sanitizeCrashReportString(`repair: ${String(error)}`, 200) + } + } + record(WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB, data) + args.onDone?.(result) +} + +// Why once per process: the DACL cannot usefully change mid-session, and +// openMainWindow re-runs on re-activation. +let repairStarted = false + +export function resetWindowsInstallDirAclRepairForTest(): void { + repairStarted = false +} + +/** + * Fire-and-forget; returns before any spawn. Call only when the probe reported + * `matchesPoisonSignature`. win32 only, exempt in serve mode, and it must never + * throw into window creation. + */ +export function repairWindowsInstallDirPackageAcl(args: WindowsInstallDirAclRepairArgs): void { + if ((args.platform ?? process.platform) !== 'win32' || args.isServeMode === true) { + return + } + if (repairStarted) { + return + } + repairStarted = true + try { + setImmediate(() => { + void runRepair(args).catch(() => undefined) + }) + } catch { + // Nothing left to report to that would not throw again. + } +} diff --git a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts index 76a2c247965..43dac231130 100644 --- a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts +++ b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts @@ -21,7 +21,7 @@ vi.mock('../browser/browser-client-page-renderer-runtime', async () => { } }) -import { createMainWindow } from './createMainWindow' +import { createMainWindow, loadMainWindow } from './createMainWindow' import { ipcMain } from 'electron' import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' import { @@ -593,6 +593,40 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) + it('serves a manual retry while the breaker stays open for the next crash', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const onRendererRecoveryExhausted = vi.fn() + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + + createMainWindow(null, { onRendererRecoveryExhausted }) + + const details = { reason: 'crashed', exitCode: 5 } as Electron.RenderProcessGoneDetails + const driveCrashCycle = (): void => { + windowHandlers['render-process-gone']?.({} as never, details) + vi.advanceTimersByTime(250) + } + driveCrashCycle() + driveCrashCycle() + driveCrashCycle() + driveCrashCycle() + expect(onRendererRecoveryExhausted).toHaveBeenCalledTimes(1) + // 1 initial load + 3 recoveries; the 4th crash was refused. + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4) + + // The recovery prompt's Reload button goes straight to loadMainWindow, which the breaker never gates. + loadMainWindow(browserWindowInstance as unknown as Electron.BrowserWindow) + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(5) + + // Still-poisoned machine: the next crash re-raises the prompt immediately instead of re-arming auto-reloads. + driveCrashCycle() + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(5) + expect(onRendererRecoveryExhausted).toHaveBeenCalledTimes(2) + + consoleError.mockRestore() + }) + it('bounds renderer launch-failed recovery with the crash-loop breaker', () => { vi.useFakeTimers() diff --git a/src/main/window/renderer-recovery-prompt.test.ts b/src/main/window/renderer-recovery-prompt.test.ts new file mode 100644 index 00000000000..d5bd7ce11c1 --- /dev/null +++ b/src/main/window/renderer-recovery-prompt.test.ts @@ -0,0 +1,112 @@ +import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' +import { describe, expect, it, vi } from 'vitest' +import type { InstallDirAclPoisonDiagnosis } from '../startup/windows-install-dir-acl-recovery' +import { + presentRendererRecoveryPrompt, + type RendererRecoveryPromptDeps +} from './renderer-recovery-prompt' + +const POISON: InstallDirAclPoisonDiagnosis = { + detail: "Windows permissions on Orca's install folder are blocking its own sandboxed processes.", + commands: ['icacls "C:\\Orca" /grant "*S-1-15-2-2:(OI)(CI)(RX)"', 'icacls "C:\\Orca" /grant b'] +} + +function harness(overrides: Partial & { responses?: number[] } = {}): { + run: () => Promise + shown: MessageBoxOptions[] + copied: string[] + reload: ReturnType + quit: ReturnType +} { + const { responses = [0], ...rest } = overrides + const shown: MessageBoxOptions[] = [] + const copied: string[] = [] + const reload = vi.fn() + const quit = vi.fn() + const deps: RendererRecoveryPromptDeps = { + recentRecoveryCount: 4, + isQuitting: () => false, + diagnose: () => null, + showMessageBox: async (options: MessageBoxOptions): Promise => { + shown.push(options) + return { + response: responses[Math.min(shown.length - 1, responses.length - 1)], + checkboxChecked: false + } + }, + copyToClipboard: (text) => copied.push(text), + reload, + quit, + ...rest + } + return { run: () => presentRendererRecoveryPrompt(deps), shown, copied, reload, quit } +} + +describe('presentRendererRecoveryPrompt', () => { + it('offers reload and quit with the generic cause when nothing is diagnosed', async () => { + const { run, shown, reload, quit } = harness({ responses: [0] }) + await run() + expect(shown).toHaveLength(1) + expect(shown[0].buttons).toEqual(['Reload', 'Quit']) + expect(shown[0].cancelId).toBe(1) + expect(shown[0].detail).toContain('graphics-driver or installation problem') + expect(reload).toHaveBeenCalledOnce() + expect(quit).not.toHaveBeenCalled() + }) + + it('quits on the last button', async () => { + const { run, reload, quit } = harness({ responses: [1] }) + await run() + expect(quit).toHaveBeenCalledOnce() + expect(reload).not.toHaveBeenCalled() + }) + + it('names the install-permission cause and keeps the driver hint', async () => { + const { run, shown } = harness({ diagnose: () => POISON, responses: [0] }) + await run() + expect(shown[0].buttons).toEqual(['Reload', 'Copy Commands', 'Quit']) + expect(shown[0].cancelId).toBe(2) + expect(shown[0].detail).toContain(POISON.detail) + expect(shown[0].detail).toContain('graphics driver') + }) + + // The window is blank, so dismissing the dialog to copy would leave no way back. + it('keeps the dialog up after copying the commands, then still reloads', async () => { + const { run, shown, copied, reload, quit } = harness({ + diagnose: () => POISON, + responses: [1, 1, 0] + }) + await run() + expect(copied).toEqual([POISON.commands.join('\r\n'), POISON.commands.join('\r\n')]) + expect(shown).toHaveLength(3) + expect(reload).toHaveBeenCalledOnce() + expect(quit).not.toHaveBeenCalled() + }) + + it('quits on the third button once the diagnosis adds one', async () => { + const { run, quit, copied } = harness({ diagnose: () => POISON, responses: [2] }) + await run() + expect(quit).toHaveBeenCalledOnce() + expect(copied).toEqual([]) + }) + + it('shows nothing once the app is already quitting', async () => { + const { run, shown } = harness({ isQuitting: () => true }) + await run() + expect(shown).toEqual([]) + }) + + // The repair lands asynchronously, so a prompt raised while it ran must pick up + // the settled copy on the next pass rather than keep saying "repairing now". + it('re-reads the diagnosis on every pass', async () => { + const details = ['repairing now', 'repaired'] + let pass = 0 + const { run, shown } = harness({ + diagnose: () => ({ detail: details[Math.min(pass++, 1)], commands: POISON.commands }), + responses: [1, 0] + }) + await run() + expect(shown[0].detail).toContain('repairing now') + expect(shown[1].detail).toContain('repaired') + }) +}) diff --git a/src/main/window/renderer-recovery-prompt.ts b/src/main/window/renderer-recovery-prompt.ts new file mode 100644 index 00000000000..2026d1f10b5 --- /dev/null +++ b/src/main/window/renderer-recovery-prompt.ts @@ -0,0 +1,55 @@ +import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' +import type { InstallDirAclPoisonDiagnosis } from '../startup/windows-install-dir-acl-recovery' + +/** + * The dialog shown when the renderer crash-loop breaker opens: the window is + * blank by then, so this is the only retry/quit surface the user has. + */ + +const GENERIC_DETAIL = + 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' +// Why keep it alongside the ACL diagnosis: the probe cannot name-check every +// locale, so a driver crash on a healthy install must not lose its only hint. +const DRIVER_FALLBACK = 'If that does not help, the cause is usually a graphics driver.' + +export type RendererRecoveryPromptDeps = { + recentRecoveryCount: number + isQuitting: () => boolean + diagnose: () => InstallDirAclPoisonDiagnosis | null + showMessageBox: (options: MessageBoxOptions) => Promise + copyToClipboard: (text: string) => void + reload: () => void + quit: () => void +} + +export async function presentRendererRecoveryPrompt( + deps: RendererRecoveryPromptDeps +): Promise { + // Why a loop: copying the commands must not dismiss the only surface offering them. + while (!deps.isQuitting()) { + const diagnosis = deps.diagnose() + const buttons = diagnosis ? ['Reload', 'Copy Commands', 'Quit'] : ['Reload', 'Quit'] + const { response } = await deps.showMessageBox({ + type: 'error', + buttons, + defaultId: 0, + cancelId: buttons.length - 1, + title: 'Orca keeps failing to load', + message: 'The app window crashed repeatedly and stopped reloading automatically.', + detail: `Orca tried to recover ${deps.recentRecoveryCount} times in a row without success.\n\n${ + diagnosis ? `${diagnosis.detail}\n\n${DRIVER_FALLBACK}` : GENERIC_DETAIL + }` + }) + const choice = buttons[response] + if (choice === 'Copy Commands' && diagnosis) { + deps.copyToClipboard(diagnosis.commands.join('\r\n')) + continue + } + if (choice === 'Reload') { + deps.reload() + } else if (choice === 'Quit') { + deps.quit() + } + return + } +}