mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
fix(startup): Windows never crashes resolving userData when roaming AppData is unavailable (#25113)
* fix(startup): pin Windows appData and userData before anything resolves them A Windows session without a loaded profile (e.g. orca serve over SSH) can fail the roaming AppData known-folder lookup. Electron 43 then falls through to Chromium's userData provider and crashes natively. Resolve appData first (falling back to APPDATA, then USERPROFILE\AppData\Roaming), and set userData explicitly so Electron's provider never runs. * test(startup): remove the AppData fixture through the retrying helper --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -107,6 +107,7 @@ import { initializeBrowserIdentityModeStore } from '../browser/browser-identity-
|
||||
import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access'
|
||||
import { getActiveProfileStateLocation } from '../persistence/profile-state/profile-state-active-location'
|
||||
import { handleMainProcessPreflightFailure } from './main-process-preflight-failure'
|
||||
import { ensureWindowsAppDataPath } from './windows-app-data-path'
|
||||
|
||||
export type MainProcessPreflightOptions = {
|
||||
focusExistingWindow: () => void
|
||||
@@ -125,6 +126,8 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b
|
||||
}
|
||||
|
||||
function initializeMainProcessPreflight(options: MainProcessPreflightOptions): boolean {
|
||||
// Why first: every step below, recovery and the instance lock included, may resolve userData.
|
||||
ensureWindowsAppDataPath(app)
|
||||
if (runProfileStateRecoveryPreflight()) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { existsSync, mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, win32 } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
|
||||
import {
|
||||
deriveWindowsAppDataPath,
|
||||
ensureWindowsAppDataPath,
|
||||
type WindowsAppDataPathHost
|
||||
} from './windows-app-data-path'
|
||||
|
||||
const ERROR_MESSAGE = /could not find the Windows roaming AppData folder/
|
||||
|
||||
function createHost(nativeAppData: string | Error): WindowsAppDataPathHost & {
|
||||
calls: string[]
|
||||
paths: Map<string, string>
|
||||
} {
|
||||
const calls: string[] = []
|
||||
const paths = new Map<string, string>()
|
||||
return {
|
||||
calls,
|
||||
paths,
|
||||
getName: () => 'orca',
|
||||
getPath: (name) => {
|
||||
calls.push(`get:${name}`)
|
||||
const overridden = paths.get(name)
|
||||
if (overridden) {
|
||||
return overridden
|
||||
}
|
||||
if (nativeAppData instanceof Error) {
|
||||
throw nativeAppData
|
||||
}
|
||||
return nativeAppData
|
||||
},
|
||||
setPath: (name, value) => {
|
||||
calls.push(`set:${name}`)
|
||||
paths.set(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tempDirs: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
removeTreeSync(dir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('deriveWindowsAppDataPath', () => {
|
||||
it('prefers APPDATA', () => {
|
||||
expect(
|
||||
deriveWindowsAppDataPath({
|
||||
APPDATA: 'D:\\Profiles\\me\\Roaming',
|
||||
USERPROFILE: 'C:\\Users\\me'
|
||||
})
|
||||
).toBe('D:\\Profiles\\me\\Roaming')
|
||||
})
|
||||
|
||||
it('falls back to USERPROFILE\\AppData\\Roaming when APPDATA is unset or relative', () => {
|
||||
expect(deriveWindowsAppDataPath({ USERPROFILE: 'C:\\Users\\me' })).toBe(
|
||||
'C:\\Users\\me\\AppData\\Roaming'
|
||||
)
|
||||
expect(deriveWindowsAppDataPath({ APPDATA: 'Roaming', USERPROFILE: 'C:\\Users\\me' })).toBe(
|
||||
'C:\\Users\\me\\AppData\\Roaming'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws a readable error when neither variable is usable', () => {
|
||||
expect(() => deriveWindowsAppDataPath({})).toThrow(ERROR_MESSAGE)
|
||||
expect(() => deriveWindowsAppDataPath({ APPDATA: ' ', USERPROFILE: '' })).toThrow(ERROR_MESSAGE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureWindowsAppDataPath', () => {
|
||||
it('does nothing off Windows', () => {
|
||||
const host = createHost(new Error('unused'))
|
||||
ensureWindowsAppDataPath(host, {}, 'darwin')
|
||||
expect(host.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the native appData and pins userData to the path Electron would derive', () => {
|
||||
const host = createHost('C:\\Users\\me\\AppData\\Roaming')
|
||||
ensureWindowsAppDataPath(host, { APPDATA: 'D:\\ignored' }, 'win32')
|
||||
expect(host.calls).toEqual(['get:appData', 'set:userData'])
|
||||
expect(host.paths.get('userData')).toBe('C:\\Users\\me\\AppData\\Roaming\\orca')
|
||||
})
|
||||
|
||||
it('creates and sets appData from the environment before userData when the lookup throws', () => {
|
||||
// Why a real dir: the fallback must mkdir a folder that a profile-less session never created.
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-appdata-'))
|
||||
tempDirs.push(root)
|
||||
const missingAppData = join(root, 'missing', 'Roaming')
|
||||
const host = createHost(new Error('Failed to get appData path'))
|
||||
|
||||
ensureWindowsAppDataPath(host, { APPDATA: missingAppData }, 'win32')
|
||||
|
||||
expect(host.calls).toEqual(['get:appData', 'set:appData', 'set:userData'])
|
||||
expect(host.paths.get('appData')).toBe(missingAppData)
|
||||
expect(host.paths.get('userData')).toBe(win32.join(missingAppData, 'orca'))
|
||||
expect(existsSync(missingAppData)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an empty native appData like a failed lookup', () => {
|
||||
const host = createHost('')
|
||||
expect(() => ensureWindowsAppDataPath(host, {}, 'win32')).toThrow(ERROR_MESSAGE)
|
||||
expect(host.calls).toEqual(['get:appData'])
|
||||
})
|
||||
|
||||
it('fails with a readable error, setting nothing, when both variables are missing', () => {
|
||||
const host = createHost(new Error('Failed to get appData path'))
|
||||
expect(() => ensureWindowsAppDataPath(host, {}, 'win32')).toThrow(ERROR_MESSAGE)
|
||||
expect(host.paths.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startup ordering', () => {
|
||||
it('pins Windows appData before any preflight step can resolve userData', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src/main/startup/main-process-preflight.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const body = source.slice(source.indexOf('function initializeMainProcessPreflight('))
|
||||
const ensure = body.indexOf('ensureWindowsAppDataPath(app)')
|
||||
expect(ensure).toBeGreaterThan(0)
|
||||
for (const laterStep of [
|
||||
'runProfileStateRecoveryPreflight()',
|
||||
'maybeRedirectCliLaunch(',
|
||||
'configureDevUserDataPath(',
|
||||
'configureOrcaUserDataPathEnv()',
|
||||
'startCrashpadCapture()'
|
||||
]) {
|
||||
expect(body.indexOf(laterStep)).toBeGreaterThan(ensure)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { win32 } from 'node:path'
|
||||
|
||||
export type WindowsAppDataPathHost = {
|
||||
getPath(name: 'appData'): string
|
||||
setPath(name: 'appData' | 'userData', value: string): void
|
||||
getName(): string
|
||||
}
|
||||
|
||||
/** Derives the roaming AppData folder from the environment when the known-folder lookup fails. */
|
||||
export function deriveWindowsAppDataPath(env: NodeJS.ProcessEnv): string {
|
||||
const appData = env.APPDATA?.trim()
|
||||
if (appData && win32.isAbsolute(appData)) {
|
||||
return appData
|
||||
}
|
||||
const userProfile = env.USERPROFILE?.trim()
|
||||
if (userProfile && win32.isAbsolute(userProfile)) {
|
||||
return win32.join(userProfile, 'AppData', 'Roaming')
|
||||
}
|
||||
throw new Error(
|
||||
'Orca could not find the Windows roaming AppData folder: Windows did not report it, and neither APPDATA nor USERPROFILE is set.'
|
||||
)
|
||||
}
|
||||
|
||||
function readNativeAppDataPath(host: WindowsAppDataPathHost): string | null {
|
||||
try {
|
||||
return host.getPath('appData') || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins appData and userData before anything resolves userData on Windows.
|
||||
*
|
||||
* Why: when Electron cannot resolve or create userData itself (no loaded profile, e.g. over SSH), it falls
|
||||
* through to Chromium's provider, which dereferences null and crashes natively before crashpad connects.
|
||||
*/
|
||||
export function ensureWindowsAppDataPath(
|
||||
host: WindowsAppDataPathHost,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): void {
|
||||
if (platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
let appData = readNativeAppDataPath(host)
|
||||
if (!appData) {
|
||||
appData = deriveWindowsAppDataPath(env)
|
||||
mkdirSync(appData, { recursive: true })
|
||||
host.setPath('appData', appData)
|
||||
}
|
||||
// Same value Electron's own provider computes; setting it skips that provider entirely.
|
||||
host.setPath('userData', win32.join(appData, host.getName()))
|
||||
}
|
||||
Reference in New Issue
Block a user