mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
44137ccb91
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
199 lines
6.7 KiB
TypeScript
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,
|
|
})
|
|
}
|