mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(windows): repair the install-dir package ACL that blanks the window (#17740)
* fix(windows): repair the install-dir package ACL that blanks the window An install tree carrying an orphan AppContainer ACE (S-1-15-2-<x>) with no ALL RESTRICTED APPLICATION PACKAGES grant denies Chromium's LPAC children read on the shipped modules; they die at init with 0x80000003 and the window stays blank forever (electron/electron#51761). - Tighten the probe verdict to require the S-1-15-2-2 grant specifically: an ALL APPLICATION PACKAGES (S-1-15-2-1) ACE, the Program Files default, does not appear in an LPAC token and cannot satisfy the orphan. - Drop BUILTIN from the English-locale heuristic (fr-FR/es-ES print it verbatim) so a localized icacls is correctly reported as un-name-checkable. - Add an additive, marker-guarded icacls self-repair: an inheritable root grant plus a flagless (RX) /T pass, never /grant:r. - Route the crash-loop dialog through a testable prompt module that names the permission cause, offers Copy Commands without dismissing itself, and keeps the graphics-driver hint. The repair only runs on win32, off serve mode, and only on the exact probe verdict that reproduced the crash. * docs(windows): correct the install-tree ACL walk cost model * fix(windows): keep the install-ACL poison gate at the reproduced shape An orphan package ACE alongside the Program Files ALL APPLICATION PACKAGES default launches clean on win32 10.0.26200 / Electron 43.4.1, so requiring S-1-15-2-2 specifically declared poison on healthy installs - and this branch acts on that verdict with a tree-wide icacls write and the crash-recovery dialog's primary cause. hasRestrictedPackageGrant stays reported for triage; only the verdict reverts.
This commit is contained in:
+39
-26
@@ -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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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<string, unknown>)
|
||||
|
||||
@@ -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<void
|
||||
const facts = outputs.map(collectAclFacts)
|
||||
const orphans = [...new Set(facts.flatMap((f) => 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<void
|
||||
// Capped: correlating the same orphan across reports is what would
|
||||
// identify the tool that left it, which is the point of recording it.
|
||||
orphanPackageSids: sanitizeCrashReportString(orphans.slice(0, 3).join(','), 200),
|
||||
// The verdict rides on this one: either well-known grant satisfies the orphan.
|
||||
hasWellKnownPackageGrant,
|
||||
// Diagnostic only — the -1-only shape launches clean on real hardware.
|
||||
hasRestrictedPackageGrant,
|
||||
// False positives are possible on a non-English Windows, where the
|
||||
// well-known ACE resolves to a localized name this cannot match.
|
||||
wellKnownNameCheckReliable: facts.some((f) => f.sawEnglishPrincipal),
|
||||
|
||||
@@ -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<ProcessSpec[]> {
|
||||
const specs: ProcessSpec[] = []
|
||||
const runProcessFn = async (spec: ProcessSpec): Promise<ProcessResult> => {
|
||||
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<ProcessSpec[]>((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<never>(() => undefined)) as never,
|
||||
recordBreadcrumb: () => undefined
|
||||
}
|
||||
)
|
||||
expect(describeInstallDirAclPoison()?.detail).toContain('repairing the permissions now')
|
||||
})
|
||||
})
|
||||
@@ -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<WindowsInstallDirAclRepairArgs, 'onDone'>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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<ProcessResult>
|
||||
|
||||
function fakeRunner(reply: (spec: ProcessSpec) => Partial<ProcessResult> = () => ({})): {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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-<x>) 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 <file> /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<RepairMarker>
|
||||
| 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<GrantOutcome> {
|
||||
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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<RendererRecoveryPromptDeps> & { responses?: number[] } = {}): {
|
||||
run: () => Promise<void>
|
||||
shown: MessageBoxOptions[]
|
||||
copied: string[]
|
||||
reload: ReturnType<typeof vi.fn>
|
||||
quit: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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<MessageBoxReturnValue> => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<MessageBoxReturnValue>
|
||||
copyToClipboard: (text: string) => void
|
||||
reload: () => void
|
||||
quit: () => void
|
||||
}
|
||||
|
||||
export async function presentRendererRecoveryPrompt(
|
||||
deps: RendererRecoveryPromptDeps
|
||||
): Promise<void> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user