443 lines
11 KiB
TypeScript
443 lines
11 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const LEGACY_USER_DATA_DIR_NAMES = [
|
|
'agent-teams-ai',
|
|
'Agent Teams UI',
|
|
'Claude Agent Teams UI',
|
|
'claude-agent-teams-ui',
|
|
'claude-devtools',
|
|
'claude-code-context',
|
|
] as const;
|
|
|
|
export interface ElectronUserDataMigrationApp {
|
|
getPath(name: string): string;
|
|
setPath?(name: string, value: string): void;
|
|
}
|
|
|
|
export interface ElectronUserDataMigrationResult {
|
|
currentPath: string | null;
|
|
legacyPath: string | null;
|
|
migrated: boolean;
|
|
fallbackToLegacy: boolean;
|
|
reason:
|
|
| 'migrated'
|
|
| 'legacy-reused'
|
|
| 'current-populated'
|
|
| 'current-path-exists'
|
|
| 'legacy-missing'
|
|
| 'legacy-fallback'
|
|
| 'error';
|
|
}
|
|
|
|
interface LoggerLike {
|
|
info(message: string): void;
|
|
warn(message: string): void;
|
|
}
|
|
|
|
interface ElectronUserDataMigrationOptions {
|
|
logger?: LoggerLike;
|
|
copyDirectory?: (sourcePath: string, targetPath: string) => void;
|
|
strategy?: 'reuse-legacy' | 'copy';
|
|
}
|
|
|
|
const TRANSIENT_CHROMIUM_DIRECTORY_NAMES = new Set([
|
|
'Cache',
|
|
'Code Cache',
|
|
'Crashpad',
|
|
'Crash Reports',
|
|
'DawnGraphiteCache',
|
|
'DawnWebGPUCache',
|
|
'GPUCache',
|
|
'GrShaderCache',
|
|
'ShaderCache',
|
|
'Session Storage',
|
|
'Shared Dictionary',
|
|
'Service Worker',
|
|
'VideoDecodeStats',
|
|
'blob_storage',
|
|
]);
|
|
|
|
const TRANSIENT_CHROMIUM_FILE_NAMES = new Set([
|
|
'DIPS',
|
|
'DIPS-journal',
|
|
'DIPS-wal',
|
|
'LOCK',
|
|
'Network Persistent State',
|
|
'SingletonCookie',
|
|
'SingletonLock',
|
|
'SingletonSocket',
|
|
'TransportSecurity',
|
|
'Trust Tokens',
|
|
'Trust Tokens-journal',
|
|
]);
|
|
|
|
const DURABLE_USER_DATA_ROOT_NAMES = new Set(['data', 'backups']);
|
|
const PREFERRED_USER_DATA_DIR_NAME = 'agent-teams-ai';
|
|
|
|
const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000;
|
|
|
|
export function getLegacyElectronUserDataCandidates(currentPath: string): string[] {
|
|
const parent = path.dirname(currentPath);
|
|
const normalizedCurrent = path.resolve(currentPath);
|
|
|
|
return LEGACY_USER_DATA_DIR_NAMES.map((dirName) => path.join(parent, dirName)).filter(
|
|
(legacyPath) => path.resolve(legacyPath) !== normalizedCurrent
|
|
);
|
|
}
|
|
|
|
export function migrateElectronUserDataDirectory(
|
|
app: ElectronUserDataMigrationApp,
|
|
options: ElectronUserDataMigrationOptions = {}
|
|
): ElectronUserDataMigrationResult {
|
|
const logger = options.logger;
|
|
let currentPath: string;
|
|
|
|
try {
|
|
currentPath = app.getPath('userData');
|
|
scheduleStaleMigrationTempCleanup(currentPath, logger);
|
|
} catch (error) {
|
|
logger?.warn(`Electron userData migration skipped: ${stringifyError(error)}`);
|
|
return {
|
|
currentPath: null,
|
|
legacyPath: null,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'error',
|
|
};
|
|
}
|
|
|
|
const preferredExistingPath = selectPreferredElectronUserDataPath(currentPath);
|
|
if (preferredExistingPath) {
|
|
try {
|
|
setLegacyElectronPaths(app, preferredExistingPath, logger);
|
|
logger?.info(`Reusing preferred Electron userData at ${preferredExistingPath}`);
|
|
return {
|
|
currentPath,
|
|
legacyPath: preferredExistingPath,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'legacy-reused',
|
|
};
|
|
} catch (error) {
|
|
logger?.warn(`Electron userData preferred reuse failed: ${stringifyError(error)}`);
|
|
return {
|
|
currentPath,
|
|
legacyPath: preferredExistingPath,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'error',
|
|
};
|
|
}
|
|
}
|
|
|
|
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
|
|
return {
|
|
currentPath,
|
|
legacyPath: null,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'current-populated',
|
|
};
|
|
}
|
|
|
|
if (pathExists(currentPath) && !directoryExists(currentPath)) {
|
|
logger?.warn(`Electron userData migration skipped: current path is not a directory`);
|
|
return {
|
|
currentPath,
|
|
legacyPath: null,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'current-path-exists',
|
|
};
|
|
}
|
|
|
|
const legacyPath = selectLegacyElectronUserDataPath(currentPath);
|
|
if (!legacyPath) {
|
|
return {
|
|
currentPath,
|
|
legacyPath: null,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'legacy-missing',
|
|
};
|
|
}
|
|
|
|
if ((options.strategy ?? 'reuse-legacy') === 'reuse-legacy') {
|
|
try {
|
|
setLegacyElectronPaths(app, legacyPath, logger);
|
|
logger?.info(`Reusing legacy Electron userData at ${legacyPath}`);
|
|
return {
|
|
currentPath,
|
|
legacyPath,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'legacy-reused',
|
|
};
|
|
} catch (error) {
|
|
logger?.warn(`Electron userData legacy reuse failed: ${stringifyError(error)}`);
|
|
return {
|
|
currentPath,
|
|
legacyPath,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'error',
|
|
};
|
|
}
|
|
}
|
|
|
|
const migrated = copyLegacyUserDataDirectory(
|
|
legacyPath,
|
|
currentPath,
|
|
logger,
|
|
options.copyDirectory
|
|
);
|
|
if (migrated) {
|
|
logger?.info(`Migrated Electron userData from ${legacyPath} to ${currentPath}`);
|
|
return {
|
|
currentPath,
|
|
legacyPath,
|
|
migrated: true,
|
|
fallbackToLegacy: false,
|
|
reason: 'migrated',
|
|
};
|
|
}
|
|
|
|
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
|
|
return {
|
|
currentPath,
|
|
legacyPath: null,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'current-populated',
|
|
};
|
|
}
|
|
|
|
try {
|
|
setLegacyElectronPaths(app, legacyPath, logger);
|
|
logger?.warn(`Electron userData migration failed, using legacy path for this run`);
|
|
return {
|
|
currentPath,
|
|
legacyPath,
|
|
migrated: false,
|
|
fallbackToLegacy: true,
|
|
reason: 'legacy-fallback',
|
|
};
|
|
} catch (error) {
|
|
logger?.warn(`Electron userData legacy fallback failed: ${stringifyError(error)}`);
|
|
return {
|
|
currentPath,
|
|
legacyPath,
|
|
migrated: false,
|
|
fallbackToLegacy: false,
|
|
reason: 'error',
|
|
};
|
|
}
|
|
}
|
|
|
|
function selectLegacyElectronUserDataPath(currentPath: string): string | null {
|
|
return (
|
|
getLegacyElectronUserDataCandidates(currentPath)
|
|
.filter(directoryExists)
|
|
.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ?? null
|
|
);
|
|
}
|
|
|
|
function selectPreferredElectronUserDataPath(currentPath: string): string | null {
|
|
const preferredPath = path.join(path.dirname(currentPath), PREFERRED_USER_DATA_DIR_NAME);
|
|
if (path.resolve(preferredPath) === path.resolve(currentPath)) {
|
|
return null;
|
|
}
|
|
return directoryExists(preferredPath) && directoryHasDurableUserDataEntries(preferredPath)
|
|
? preferredPath
|
|
: null;
|
|
}
|
|
|
|
function setLegacyElectronPaths(
|
|
app: ElectronUserDataMigrationApp,
|
|
legacyPath: string,
|
|
logger?: LoggerLike
|
|
): void {
|
|
app.setPath?.('userData', legacyPath);
|
|
try {
|
|
app.setPath?.('sessionData', legacyPath);
|
|
} catch (error) {
|
|
logger?.warn(`Electron sessionData legacy fallback failed: ${stringifyError(error)}`);
|
|
}
|
|
}
|
|
|
|
function copyLegacyUserDataDirectory(
|
|
legacyPath: string,
|
|
currentPath: string,
|
|
logger?: LoggerLike,
|
|
copyDirectory: (sourcePath: string, targetPath: string) => void = copyDirectorySync
|
|
): boolean {
|
|
const parent = path.dirname(currentPath);
|
|
const tempPath = path.join(
|
|
parent,
|
|
`${path.basename(currentPath)}.migrating-${process.pid}-${Date.now()}`
|
|
);
|
|
|
|
try {
|
|
fs.mkdirSync(parent, { recursive: true });
|
|
|
|
if (pathExists(tempPath)) {
|
|
fs.rmSync(tempPath, { recursive: true, force: true });
|
|
}
|
|
|
|
copyDirectory(legacyPath, tempPath);
|
|
|
|
if (directoryExists(currentPath) && directoryIsEmpty(currentPath)) {
|
|
fs.rmdirSync(currentPath);
|
|
}
|
|
|
|
fs.renameSync(tempPath, currentPath);
|
|
return true;
|
|
} catch (error) {
|
|
logger?.warn(`Electron userData migration copy failed: ${stringifyError(error)}`);
|
|
try {
|
|
if (pathExists(tempPath)) {
|
|
fs.rmSync(tempPath, { recursive: true, force: true });
|
|
}
|
|
} catch {
|
|
// Best effort cleanup only.
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function copyDirectorySync(sourcePath: string, targetPath: string): void {
|
|
fs.cpSync(sourcePath, targetPath, {
|
|
recursive: true,
|
|
errorOnExist: false,
|
|
force: false,
|
|
filter: (sourceEntryPath) => shouldCopyElectronUserDataEntry(sourcePath, sourceEntryPath),
|
|
});
|
|
}
|
|
|
|
function scheduleStaleMigrationTempCleanup(currentPath: string, logger?: LoggerLike): void {
|
|
const parent = path.dirname(currentPath);
|
|
const prefix = `${path.basename(currentPath)}.migrating-`;
|
|
|
|
const timeout = setTimeout(() => {
|
|
fs.readdir(parent, { withFileTypes: true }, (readError, entries) => {
|
|
if (readError) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) {
|
|
continue;
|
|
}
|
|
|
|
const stalePath = path.join(parent, entry.name);
|
|
fs.stat(stalePath, (statError, stats) => {
|
|
if (statError || now - stats.mtimeMs < STALE_MIGRATION_TEMP_MAX_AGE_MS) {
|
|
return;
|
|
}
|
|
|
|
fs.rm(stalePath, { recursive: true, force: true }, (removeError) => {
|
|
if (removeError) {
|
|
logger?.warn(
|
|
`Failed to remove stale Electron userData migration temp path: ${stringifyError(
|
|
removeError
|
|
)}`
|
|
);
|
|
return;
|
|
}
|
|
logger?.info(`Removed stale Electron userData migration temp path: ${stalePath}`);
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}, 30_000);
|
|
|
|
timeout.unref?.();
|
|
}
|
|
|
|
export function shouldCopyElectronUserDataEntry(
|
|
sourceRootPath: string,
|
|
sourceEntryPath: string
|
|
): boolean {
|
|
const relativePath = path.relative(sourceRootPath, sourceEntryPath);
|
|
if (!relativePath || relativePath === '.') {
|
|
return true;
|
|
}
|
|
|
|
const segments = relativePath.split(path.sep).filter(Boolean);
|
|
if (segments.some((segment) => TRANSIENT_CHROMIUM_DIRECTORY_NAMES.has(segment))) {
|
|
return false;
|
|
}
|
|
|
|
const basename = segments[segments.length - 1];
|
|
if (TRANSIENT_CHROMIUM_FILE_NAMES.has(basename)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function pathExists(targetPath: string): boolean {
|
|
try {
|
|
fs.accessSync(targetPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function directoryExists(targetPath: string): boolean {
|
|
try {
|
|
return fs.statSync(targetPath).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function directoryIsEmpty(targetPath: string): boolean {
|
|
try {
|
|
return fs.readdirSync(targetPath).length === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function directoryHasDurableUserDataEntries(targetPath: string): boolean {
|
|
try {
|
|
return directoryHasDurableUserDataEntriesWithin(targetPath, targetPath);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function directoryHasDurableUserDataEntriesWithin(rootPath: string, targetPath: string): boolean {
|
|
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(targetPath, entry.name);
|
|
const relativePath = path.relative(rootPath, entryPath);
|
|
const rootSegment = relativePath.split(path.sep).find(Boolean);
|
|
if (!rootSegment || !DURABLE_USER_DATA_ROOT_NAMES.has(rootSegment)) {
|
|
continue;
|
|
}
|
|
|
|
if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) {
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isDirectory()) {
|
|
return true;
|
|
}
|
|
|
|
if (directoryHasDurableUserDataEntriesWithin(rootPath, entryPath)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function stringifyError(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|