Files
claude-code-haha/desktop/electron/services/appMode.ts
T
程序员阿江(Relakkes) 44137ccb91 fix(desktop): stop built-in updates from tripping the installer legacy-data guard #1160
The NSIS installer spawned by quitAndInstall() inherits the app process
environment, including the app-managed CLAUDE_CONFIG_DIR /
CC_HAHA_APP_PORTABLE_DIR pair that applyStartupPortableMode() derives
from app-mode.json. The installer's recovery helper then re-validated
that snapshot against the persisted mode it reads via its own APPDATA
and blocked the whole upgrade on any disagreement (mode switched without
a restart, APPDATA differing from the app's known-folder view), even
though an active data directory outside every install directory cannot
be touched by removing the old version. Manually launched setups never
saw the variables, which is why they kept working.

- clear the app-managed portable env before handing off to the spawned
  installer (shared with the existing app.relaunch() cleanup), so
  built-in updates present the same clean environment as a manual setup
- downgrade the recovery helper's managed-mode consistency check from
  fail-closed to treating the directory as externally managed; the
  install-contained legacy data guard below it still refuses unsafe
  removals, and matching persisted modes behave exactly as before
2026-08-01 01:35:50 +08:00

199 lines
6.7 KiB
TypeScript

import { randomUUID } from 'node:crypto'
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' | 'home' | 'userData'): string
}
type PersistedAppModeConfig = {
mode?: string
portable_dir?: string | null
}
export function systemClaudeConfigDir(app: AppModeAppLike): string {
return path.join(app.getPath('home'), '.claude')
}
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.trim() : null,
}
} catch {
return null
}
}
function writeAppModeConfig(configDir: string, config: PersistedAppModeConfig): void {
fs.mkdirSync(configDir, { recursive: true })
const target = path.join(configDir, APP_MODE_FILE)
const temporary = path.join(configDir, `.${APP_MODE_FILE}.${randomUUID()}.tmp`)
try {
fs.writeFileSync(temporary, JSON.stringify(config, null, 2))
fs.renameSync(temporary, target)
} finally {
fs.rmSync(temporary, { force: true })
}
}
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}`)
}
}
function resolveWithExistingAncestor(inputPath: string): string {
let existingPath = path.resolve(inputPath)
const missingSegments: string[] = []
while (!fs.existsSync(existingPath)) {
const parent = path.dirname(existingPath)
if (parent === existingPath) return path.resolve(inputPath)
missingSegments.unshift(path.basename(existingPath))
existingPath = parent
}
return path.join(fs.realpathSync.native(existingPath), ...missingSegments)
}
function isPathAtOrBelow(parentDir: string, candidateDir: string): boolean {
const relative = path.relative(
resolveWithExistingAncestor(parentDir),
resolveWithExistingAncestor(candidateDir),
)
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
}
function normalizedCustomDir(app: AppModeAppLike, value: string | null | undefined): string {
const selectedDir = value?.trim()
if (!selectedDir) throw new Error('Choose an absolute custom data directory')
if (!path.isAbsolute(selectedDir)) throw new Error('Custom data storage must use an absolute path')
const normalized = path.resolve(selectedDir)
if (isPathAtOrBelow(path.dirname(app.getPath('exe')), normalized)) {
throw new Error('Custom data storage must stay outside the application install directory')
}
return normalized
}
function externallyControlled(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.CLAUDE_CONFIG_DIR && env.CC_HAHA_APP_PORTABLE_DIR !== '1')
}
// The app-managed portable selection is process-local derived state; the
// persisted app-mode.json stays the source of truth. Strip it before this
// environment reaches another process (app.relaunch(), the NSIS installer
// spawned by quitAndInstall()), otherwise the child would trust a snapshot
// that may no longer match the persisted mode (#1160).
export function clearAppManagedPortableEnv(env: NodeJS.ProcessEnv = process.env): void {
if (env.CC_HAHA_APP_PORTABLE_DIR !== '1') return
delete env.CLAUDE_CONFIG_DIR
delete env.CC_HAHA_APP_PORTABLE_DIR
delete env.WEBVIEW2_USER_DATA_FOLDER
}
export function determineStartupPortableDir(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): string | null {
if (env.CLAUDE_CONFIG_DIR) return null
const config = readAppModeConfig(app.getPath('userData'))
if (config?.mode !== 'portable' || !config.portable_dir || !path.isAbsolute(config.portable_dir)) return null
try {
return normalizedCustomDir(app, config.portable_dir)
} catch {
return null
}
}
export function applyStartupPortableMode(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): string | null {
// app.relaunch() inherits process.env. Discard the previous app-managed
// selection so the persisted two-mode record remains authoritative.
clearAppManagedPortableEnv(env)
if (env.CLAUDE_CONFIG_DIR) {
env.CLAUDE_CONFIG_DIR = normalizedCustomDir(app, env.CLAUDE_CONFIG_DIR)
return null
}
const customDir = determineStartupPortableDir(app, env)
if (!customDir) return null
const webViewDataDir = path.join(customDir, 'EBWebView')
fs.mkdirSync(webViewDataDir, { recursive: true })
env.CLAUDE_CONFIG_DIR = customDir
env.CC_HAHA_APP_PORTABLE_DIR = '1'
env.WEBVIEW2_USER_DATA_FOLDER = webViewDataDir
return customDir
}
export function getAppMode(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): AppModeConfig {
const envConfigDir = env.CLAUDE_CONFIG_DIR
? normalizedCustomDir(app, env.CLAUDE_CONFIG_DIR)
: null
const persistedCustomDir = envConfigDir ? null : determineStartupPortableDir(app, env)
const customDir = envConfigDir || persistedCustomDir
if (customDir) {
return {
mode: 'portable',
portableDir: customDir,
activeConfigDir: customDir,
configDirSource: envConfigDir && env.CC_HAHA_APP_PORTABLE_DIR !== '1'
? 'environment'
: 'portable',
}
}
return {
mode: 'default',
portableDir: null,
activeConfigDir: systemClaudeConfigDir(app),
configDirSource: 'system',
}
}
export function setAppMode(
app: AppModeAppLike,
input: AppModeSetInput,
env: NodeJS.ProcessEnv = process.env,
): void {
if (externallyControlled(env)) {
throw new Error('CLAUDE_CONFIG_DIR is controlled by the launch environment')
}
if (input.mode === 'default') {
writeAppModeConfig(app.getPath('userData'), { mode: 'default', portable_dir: null })
return
}
if (input.mode !== 'portable') throw new Error(`Unsupported app mode: ${String(input.mode)}`)
const selectedDir = normalizedCustomDir(app, input.portableDir)
if (fs.existsSync(selectedDir) && !fs.statSync(selectedDir).isDirectory()) {
throw new Error(`Custom data storage path is not a directory: ${selectedDir}`)
}
assertWritableDataDir(selectedDir)
writeAppModeConfig(app.getPath('userData'), {
mode: 'portable',
portable_dir: selectedDir,
})
}