程序员阿江(Relakkes) 718e80f8c5 fix(desktop): harden portable storage writes (#949)
Guard best-effort Electron window-state persistence so an unwritable data directory cannot crash the main process. Validate portable data directories before app-mode persistence and surface failures back to settings so restart is not attempted after rollback.

Tested: cd desktop && bun test electron/services/windows.test.ts electron/services/appMode.test.ts
Tested: cd desktop && bun run test src/stores/settingsStore.test.ts -t "settingsStore app mode" --run
Tested: cd desktop && bun run check:electron
Tested: bun run check:desktop
Not-tested: bun run check:native; bun run verify; coverage gates not run for scoped local handoff.
Confidence: high
Scope-risk: narrow
2026-07-03 18:23:35 +08:00

173 lines
5.3 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import type { AppModeConfig, AppModeSetInput } from '../../src/lib/desktopHost/types'
const APP_MODE_FILE = 'app-mode.json'
export type AppModeAppLike = {
getPath(name: 'exe' | 'userData'): string
}
type PersistedAppModeConfig = {
mode?: string
portable_dir?: string | null
}
export type PortableDetection = {
defaultPortableDir: string | null
hasData: boolean
}
export function defaultPortableDir(app: AppModeAppLike): string {
return path.join(path.dirname(app.getPath('exe')), 'CLAUDE_CONFIG_DIR')
}
export function dirHasPortableData(dir: string): boolean {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return false
return [
'settings.json',
'.claude.json',
'.mcp.json',
'window-state.json',
'terminal-config.json',
].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isFile())
|| [
'Cache',
'EBWebView',
'projects',
'skills',
'plugins',
'cowork_plugins',
'cc-haha',
].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isDirectory())
}
export function readAppModeConfig(configDir: string): PersistedAppModeConfig | null {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(configDir, APP_MODE_FILE), 'utf8')) as PersistedAppModeConfig
return {
mode: typeof parsed.mode === 'string' ? parsed.mode.toLowerCase() : 'default',
portable_dir: typeof parsed.portable_dir === 'string' ? parsed.portable_dir : null,
}
} catch {
return null
}
}
export function writeAppModeConfig(configDir: string, config: PersistedAppModeConfig): void {
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, APP_MODE_FILE), JSON.stringify(config, null, 2))
}
function assertWritableDataDir(configDir: string): void {
try {
fs.mkdirSync(configDir, { recursive: true })
const probeDir = fs.mkdtempSync(path.join(configDir, '.cc-haha-write-test-'))
try {
fs.writeFileSync(path.join(probeDir, 'probe'), '')
} finally {
fs.rmSync(probeDir, { recursive: true, force: true })
}
} catch {
throw new Error(`Data storage directory is not writable: ${configDir}`)
}
}
export function determineStartupPortableDir(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): string | null {
if (env.CLAUDE_CONFIG_DIR) return null
const defaultDir = defaultPortableDir(app)
const defaultMode = readAppModeConfig(defaultDir)
if (defaultMode) {
if (defaultMode.mode === 'portable') {
return dirHasPortableData(defaultDir) ? defaultDir : defaultMode.portable_dir ?? defaultDir
}
return null
}
const systemMode = readAppModeConfig(app.getPath('userData'))
if (systemMode) {
if (systemMode.mode === 'portable') return systemMode.portable_dir ?? defaultDir
return null
}
return dirHasPortableData(defaultDir) ? defaultDir : null
}
export function applyStartupPortableMode(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): string | null {
const portableDir = determineStartupPortableDir(app, env)
if (!portableDir) return null
env.CLAUDE_CONFIG_DIR = portableDir
env.CC_HAHA_APP_PORTABLE_DIR = '1'
env.WEBVIEW2_USER_DATA_FOLDER = path.join(portableDir, 'EBWebView')
fs.mkdirSync(env.WEBVIEW2_USER_DATA_FOLDER, { recursive: true })
return portableDir
}
export function getAppMode(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): AppModeConfig {
const envConfigDir = env.CLAUDE_CONFIG_DIR || null
const activeConfigDir = envConfigDir || app.getPath('userData')
const portableDir = envConfigDir || defaultPortableDir(app)
return {
mode: envConfigDir ? 'portable' : 'default',
portableDir,
defaultPortableDir: defaultPortableDir(app),
activeConfigDir,
configDirSource: envConfigDir
? env.CC_HAHA_APP_PORTABLE_DIR ? 'portable' : 'environment'
: 'system',
}
}
export function setAppMode(
app: AppModeAppLike,
input: AppModeSetInput,
env: NodeJS.ProcessEnv = process.env,
): void {
const activeConfigDir = env.CLAUDE_CONFIG_DIR || app.getPath('userData')
let config: PersistedAppModeConfig = { mode: 'default', portable_dir: null }
let targetPortableDir: string | null = null
if (input.mode === 'portable') {
const selectedDir = input.portableDir?.trim() || defaultPortableDir(app)
if (fs.existsSync(selectedDir) && !fs.statSync(selectedDir).isDirectory()) {
throw new Error(`portable config path is not a directory: ${selectedDir}`)
}
assertWritableDataDir(selectedDir)
targetPortableDir = selectedDir
config = {
mode: 'portable',
portable_dir: selectedDir === defaultPortableDir(app) ? null : selectedDir,
}
}
const systemConfigDir = app.getPath('userData')
const configDirs = [
targetPortableDir,
activeConfigDir,
systemConfigDir,
].filter((dir): dir is string => Boolean(dir))
for (const configDir of [...new Set(configDirs)]) {
writeAppModeConfig(configDir, config)
}
}
export function detectPortableDir(app: AppModeAppLike): PortableDetection {
const portableDir = defaultPortableDir(app)
return {
defaultPortableDir: portableDir,
hasData: dirHasPortableData(portableDir),
}
}