agent-ecosystem/src/main/utils/electronUserDataMigration.ts
2026-05-08 21:48:27 +03:00

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);
}