diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index b638eb25cf..0e8a18548c 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -73,7 +73,7 @@ export default defineConfig({ "transcribe-cpp", ], input: { - index: resolve(__dirname, "electron/main/index.ts"), + index: resolve(__dirname, "electron/main/entry.ts"), // Forked per plugin by PluginRuntime (ADR 0008); must stay a // standalone entry so utilityProcess can point at a real file. "plugin-host-process": resolve(__dirname, "electron/main/plugin-host-process.mjs"), diff --git a/apps/desktop/electron/main/entry.ts b/apps/desktop/electron/main/entry.ts new file mode 100644 index 0000000000..6c36bd9f19 --- /dev/null +++ b/apps/desktop/electron/main/entry.ts @@ -0,0 +1,24 @@ +import { app, BrowserWindow } from "electron"; +import { defaultDataDir, hasSingleInstanceLock, singleInstanceRequired } from "./installation"; +import { prepareStorage } from "./storage/bootstrap"; + +// Do not top-level-await Electron readiness: module evaluation gates the ready event. +// The full composition root is imported only after offline maintenance has finished. +if (hasSingleInstanceLock) { + let booted = false; + app.on("second-instance", () => { + if (booted) return; + const window = BrowserWindow.getAllWindows()[0]; + window?.show(); + window?.focus(); + }); + void prepareStorage(defaultDataDir, !singleInstanceRequired) + .then(() => { + booted = true; + return import("./index"); + }) + .catch((error: unknown) => { + console.error("Storage startup failed", error instanceof Error ? error.message : String(error)); + app.exit(1); + }); +} diff --git a/apps/desktop/electron/main/host-process.ts b/apps/desktop/electron/main/host-process.ts index d2a5dc401a..32a91ad1d1 100644 --- a/apps/desktop/electron/main/host-process.ts +++ b/apps/desktop/electron/main/host-process.ts @@ -20,7 +20,7 @@ export type { StderrHandler, } from "@pi-desktop/host-runtime"; -function resolveHostBinary(): string { +export function resolveHostBinary(): string { if (process.env.PI_DESKTOP_HOST_BIN && existsSync(process.env.PI_DESKTOP_HOST_BIN)) { return process.env.PI_DESKTOP_HOST_BIN; } diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index bf86467a6f..0f96133820 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -13,8 +13,6 @@ import { } from "./network-proxy"; import { installInsecureEndpointNotice } from "./network-notice"; import { - APP_ID, - APP_NAME, APP_VERSION, IPC, IPC_WHITELIST, @@ -32,7 +30,7 @@ import { refreshProjectGroups, } from "./workspace-roots"; import { PersistenceOutbox } from "./persistence-outbox"; -import { Logger, ignoreBrokenStdio } from "./logger"; +import { Logger } from "./logger"; import { describeError, installMainProcessErrorHandlers } from "./main-process-errors"; import { ModelsDevCatalog, @@ -47,7 +45,8 @@ import { } from "./work-panel-window"; import { InflightCheckpointer } from "@pi-desktop/host-runtime"; import { withGitBranch } from "./workspace-git"; -import { applyDevelopmentUserData, desktopDataDir } from "./data-paths"; +import { hasSingleInstanceLock, isDevelopmentBuild } from "./installation"; +import { getStorageBootstrap } from "./storage/bootstrap"; import { createPlanUiProbe } from "./plan-ui-probe"; import { registerIpcHandlers } from "./ipc/register"; import { createVoiceService } from "./voice-service"; @@ -83,46 +82,6 @@ import { createCloseBehaviorRuntime } from "./bootstrap/close-behavior"; import { registerShutdownHandlers } from "./bootstrap/shutdown"; import { stripWinLongPrefix } from "./path-utils"; -// A closed stdout/stderr (Linux AppImage, GUI launch without a TTY) must not -// surface as Electron's "Uncaught Exception: write EPIPE" dialog. The same -// default dialog must not appear for a stray uncaughtException (non-ASCII -// HTTP headers from a system proxy, destroyed webContents, etc.). -ignoreBrokenStdio(); -installMainProcessErrorHandlers(); - -const isDevelopmentBuild = - process.env.PI_DESKTOP_DEV === "1" || !app.isPackaged; - -app.setName(APP_NAME); -applyDevelopmentUserData(app, isDevelopmentBuild); -if (process.platform === "win32") { - app.setAppUserModelId(APP_ID); -} - -// Chromium's accessibility tree serializer has a known CHECK failure in -// AXBlockFlowData::ComputeNeighborOnLine (chromium #552018997) that kills -// the renderer when an AT client reads the tree while the DOM is being -// mutated — exactly what happens during streaming agent responses. -// The switch prevents Chromium from building the in-renderer accessibility -// tree unless the user explicitly opts in via --force-renderer-accessibility. -// This is a workaround until the upstream fix lands. -app.commandLine.appendSwitch("disable-renderer-accessibility"); - -// One installation, one process. The lock lives in `userData` (set just -// above), so it is taken after `setName` and before anything else here -// touches the data directory. A development build is its own installation; -// `PI_DESKTOP_DATA_DIR` still opts a run out of the lock (E2E, capture rig). -const singleInstanceRequired = !process.env.PI_DESKTOP_DATA_DIR; -const hasSingleInstanceLock = singleInstanceRequired - ? app.requestSingleInstanceLock() - : true; -if (!hasSingleInstanceLock) { - // Nothing has booted yet: no window, no tray, no child process, no log line. - // Quit here and let the instance that holds the lock surface itself from - // `second-instance`. - app.quit(); -} - // Native resize streams can pause briefly while the pointer crosses a display // scale boundary. Keep recovery out of that gesture and only run it after the // bounds have been stable for one short interaction window. @@ -207,7 +166,8 @@ const { safeOpenExternal, } = desktopServices; -const dataDir = desktopDataDir(isDevelopmentBuild); +const storage = getStorageBootstrap(); +const dataDir = storage.preferences.roots.data; // The plugin runtime resolves this root from the environment rather than taking // it as a parameter, and a profile split across two directories is the // divergence D236 closes. @@ -868,6 +828,11 @@ const liveCallService = createLiveCallService({ function registerIpc() { return registerIpcHandlers({ + restartForStorage: () => { + shutdownState.quitConfirmed = true; + app.relaunch({ args: [...process.argv.slice(1).filter((arg) => arg !== "--pi-managed-storage"), "--pi-managed-storage"] }); + app.quit(); + }, traySessions: applicationLifecycle!.traySessions, taskbarUnreadBadge: applicationLifecycle!.taskbarUnreadBadge, ipcMain, @@ -997,7 +962,7 @@ app.on("browser-window-created", (_event, window) => { }); }); }); -app.once("ready", () => { +void app.whenReady().then(() => { installLiveMicrophonePermissionHandlers({ targetSession: session.defaultSession, getMainWindow, diff --git a/apps/desktop/electron/main/installation.ts b/apps/desktop/electron/main/installation.ts new file mode 100644 index 0000000000..185f538bc5 --- /dev/null +++ b/apps/desktop/electron/main/installation.ts @@ -0,0 +1,23 @@ +import { app } from "electron"; +import { APP_ID, APP_NAME } from "@pi-desktop/shared"; +import { applyDevelopmentUserData, desktopDataDir } from "./data-paths"; +import { ignoreBrokenStdio } from "./logger"; +import { installMainProcessErrorHandlers } from "./main-process-errors"; + +// Complete identity and locking synchronously, before the ready promise or any writer. +ignoreBrokenStdio(); +installMainProcessErrorHandlers(); +export const isDevelopmentBuild = process.env.PI_DESKTOP_DEV === "1" || !app.isPackaged; +app.setName(APP_NAME); +applyDevelopmentUserData(app, isDevelopmentBuild); +if (process.platform === "win32") app.setAppUserModelId(APP_ID); + +// A managed restart inherits the root published for children, not an explicit profile override. +if (process.argv.includes("--pi-managed-storage")) delete process.env.PI_DESKTOP_DATA_DIR; +export const singleInstanceRequired = !process.env.PI_DESKTOP_DATA_DIR; +export const hasSingleInstanceLock = singleInstanceRequired ? app.requestSingleInstanceLock() : true; +export const defaultDataDir = desktopDataDir(isDevelopmentBuild); +if (!hasSingleInstanceLock) app.quit(); + +// Preserve the existing Chromium accessibility crash workaround before ready. +app.commandLine.appendSwitch("disable-renderer-accessibility"); diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index b46aed90ae..4b30b8e571 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -21,6 +21,7 @@ import { registerProviderIpc } from "./provider-ipc"; import { registerScheduledIpc } from "./scheduled-ipc"; import { registerSessionIpc } from "./session-ipc"; import { registerSettingsIpc } from "./settings-ipc"; +import { registerStorageIpc } from "../storage/ipc"; import { registerConfigSyncIpc } from "./config-sync-ipc"; import { registerSkillsIpc } from "./skills-ipc"; import { registerAgentImportIpc } from "./agent-import-ipc"; @@ -60,6 +61,7 @@ export type RegisterIpcDependencies = { disabledBuiltinSubagents: () => Promise; liveCallService?: LiveCallService; liveVoiceWidget?: LiveVoiceWidget; + restartForStorage: () => void; mcpOAuth?: McpOAuthManager; [name: string]: any; }; @@ -237,6 +239,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { safeOpenExternal, updater, }); + registerStorageIpc({ registrar, getMainWindow, restart: dependencies.restartForStorage }); registerNotificationIpc({ registrar, getHost, diff --git a/apps/desktop/electron/main/storage/bootstrap.ts b/apps/desktop/electron/main/storage/bootstrap.ts new file mode 100644 index 0000000000..82e5a0be0b --- /dev/null +++ b/apps/desktop/electron/main/storage/bootstrap.ts @@ -0,0 +1,105 @@ +import { app, BrowserWindow, dialog } from "electron"; +import { mkdirSync, existsSync } from "node:fs"; +import { realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { catalogs, resolveLocale } from "@pi-desktop/i18n"; +import type { StorageProgress } from "@pi-desktop/shared"; +import { resolveHostBinary } from "../host-process"; +import { clearCaches, migrateFiles, removeBackups } from "./files"; +import { readStoragePreferences, STORAGE_PREFERENCE_FILE, writeStoragePreferences, type StoragePreferences } from "./preferences"; + +export type StorageBootstrap = { file: string; anchor: string; managed: boolean; preferences: StoragePreferences }; +let current: StorageBootstrap | null = null; +export function getStorageBootstrap(): StorageBootstrap { + if (!current) throw new Error("Storage bootstrap is not initialized."); + return current; +} +const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char); + +/** Runs before creating any application service or writer. userData remains the stable lock anchor. */ +export async function prepareStorage(defaultData: string, overridden: boolean): Promise { + const anchor = app.getPath("userData"); + mkdirSync(anchor, { recursive: true }); + const file = join(anchor, STORAGE_PREFERENCE_FILE); + const defaults = { data: defaultData, browser: anchor }; + let preferences: StoragePreferences; + try { + preferences = overridden ? { version: 1, roots: defaults, backups: [] } + : readStoragePreferences(file, defaults); + if (!overridden && preferences.roots.data !== defaults.data + && (!existsSync(preferences.roots.data) || !existsSync(preferences.roots.browser))) { + throw new Error("The selected storage directory is unavailable. Reconnect its drive before starting PI-Desktop."); + } + } catch (error) { + await app.whenReady(); + const copy = catalogs[resolveLocale(app.getLocale())].settings.storage; + await dialog.showMessageBox({ type: "error", title: copy.failedTitle, + message: copy.unavailableHint, detail: error instanceof Error ? error.message : String(error) }); + app.exit(1); + return new Promise(() => {}); + } + current = { file, anchor, managed: !overridden, preferences }; + // Chromium data follows the selected location while installation identity and its lock stay stable. + if (!preferences.pending) { + mkdirSync(preferences.roots.browser, { recursive: true }); + app.setPath("sessionData", preferences.roots.browser); + return current; + } + const job = preferences.pending; + // Never open a persistent session against a source being copied/cleaned. + await app.whenReady(); + const copy = catalogs[resolveLocale(job.language)].settings.storage; + const window = new BrowserWindow({ width: 560, height: 330, resizable: false, closable: false, + title: copy.progressTitle, webPreferences: { sandbox: true, contextIsolation: true, + nodeIntegration: false, partition: `storage-maintenance-${job.id}` } }); + window.setMenu(null); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event) => event.preventDefault()); + await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(`

${escapeHtml(copy.progressTitle)}

${escapeHtml(copy.progressHint)}

`)}`); + let lastPaint = 0; + let paint: Promise = Promise.resolve(); + const report = (value: StorageProgress) => { + if (Date.now() - lastPaint < 100 && !["complete", "failed", "relocating", "cleaning"].includes(value.stage)) return; + lastPaint = Date.now(); + const label = copy.stages[value.stage]; + const detail = `${value.completedFiles} / ${value.totalFiles} · ${(value.completedBytes / 1048576).toFixed(1)} / ${(value.totalBytes / 1048576).toFixed(1)} MB`; + paint = paint.then(() => { + if (window.isDestroyed()) return; + const measurable = value.totalBytes > 0 && ["copying", "verifying"].includes(value.stage); + return window.webContents.executeJavaScript(`document.getElementById('stage').textContent=${JSON.stringify(label)};document.getElementById('detail').textContent=${JSON.stringify(detail)};${measurable ? `document.querySelector('progress').max=${value.totalBytes};document.querySelector('progress').value=${value.completedBytes};` : "document.querySelector('progress').removeAttribute('value');"}`); + }).catch(() => { /* A closed maintenance surface cannot invalidate the safe on-disk job. */ }); + }; + try { + if (job.kind === "migrate") { + if (!job.target) throw new Error("Missing migration destination."); + const next = await migrateFiles({ source: preferences.roots, target: job.target, anchor, id: job.id, progress: report, + relocate: async (oldRoot, newRoot) => { + await promisify(execFile)(resolveHostBinary(), ["--relocate-data", oldRoot, newRoot], { timeout: 30 * 60_000, maxBuffer: 1024 * 1024 }); + } }); + writeStoragePreferences(file, { version: 1, roots: next, + backups: [...preferences.backups, { + data: existsSync(preferences.roots.data) ? await realpath(preferences.roots.data) : preferences.roots.data, + browser: await realpath(preferences.roots.browser), + }] }); + } else { + report({ stage: "cleaning", completedBytes: 0, totalBytes: 0, completedFiles: 0, totalFiles: 0 }); + if (job.kind === "cache") await clearCaches(preferences.roots); + else await removeBackups(preferences.backups, preferences.roots, anchor); + writeStoragePreferences(file, { ...preferences, pending: undefined, lastError: undefined, + backups: job.kind === "backup" ? [] : preferences.backups }); + } + await paint; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // No pointer change took place. Leave partial destination data for a claimed-job retry. + writeStoragePreferences(file, { ...preferences, pending: undefined, lastError: message, failedMigration: job.kind === "migrate" ? job : preferences.failedMigration }); + await dialog.showMessageBox(window, { type: "error", title: copy.failedTitle, + message: copy.failedHint, detail: message, buttons: [copy.continueOriginal] }); + } + app.relaunch(); + app.exit(0); + // app.exit terminates the process; do not initialize writers even if a test double returns. + return new Promise(() => {}); +} diff --git a/apps/desktop/electron/main/storage/files.ts b/apps/desktop/electron/main/storage/files.ts new file mode 100644 index 0000000000..32f1fdd1ef --- /dev/null +++ b/apps/desktop/electron/main/storage/files.ts @@ -0,0 +1,223 @@ +import { createHash } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { chmod, lstat, mkdir, open, readdir, readlink, realpath, rm, statfs, symlink, writeFile, readFile, stat } from "node:fs/promises"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { StorageProgress } from "@pi-desktop/shared"; +import { STORAGE_PREFERENCE_FILE, type StorageRoots } from "./preferences"; + +const OWNER_FILE = ".pi-storage-owner.json"; +export const DATA_CACHE_PATHS = ["cache", "plugins/cache/download", "plugins/cache/backup", "openable-attachments"]; +const BROWSER_CACHE_PATHS = ["Cache", "Code Cache", "GPUCache", "DawnCache", "ShaderCache", "GrShaderCache", "GraphiteDawnCache"]; +const excluded = (name: string) => name === STORAGE_PREFERENCE_FILE || name.startsWith("Singleton") || /^\.storage-.*\.tmp$/.test(name); +export function contains(parent: string, child: string): boolean { + const part = relative(parent, child); + return !part || (!part.startsWith(`..${sep}`) && part !== ".." && !isAbsolute(part)); +} +async function optionalStat(path: string) { + try { return await lstat(path); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } +} + +export async function validateTarget(path: string, source: StorageRoots, anchor: string, retryId?: string): Promise { + if (!isAbsolute(path) || path.includes("\0")) throw new Error("Select an absolute directory."); + const target = await realpath(path); + if (!(await lstat(path)).isDirectory() || (await lstat(path)).isSymbolicLink()) throw new Error("Select a real directory, not a symbolic link."); + for (const sourcePath of [source.data, source.browser, anchor]) { + const canonical = await canonicalPath(sourcePath); + if (contains(canonical, target) || contains(target, canonical)) throw new Error("The destination must be separate from the current storage directories."); + } + const names = await readdir(target); + if (names.length) { + if (!retryId) throw new Error("Select an empty destination directory."); + await assertClaimed(target, retryId, names); + } + return target; +} + +type Entry = { from: string; to: string; kind: "file" | "directory" | "link"; bytes: number; mode: number; link?: string }; +async function inventory(from: string, to: string, entries: Entry[], browser = false): Promise { + const info = await lstat(from); + if (info.isSymbolicLink()) { + entries.push({ from, to, kind: "link", bytes: 0, mode: info.mode, link: await readlink(from) }); + } else if (info.isDirectory()) { + entries.push({ from, to, kind: "directory", bytes: 0, mode: info.mode }); + for (const name of await readdir(from)) { + if (browser && excluded(name)) continue; + await inventory(join(from, name), join(to, name), entries, browser); + } + } else if (info.isFile()) { + entries.push({ from, to, kind: "file", bytes: info.size, mode: info.mode }); + } else { + throw new Error("Storage contains a special file that cannot be safely migrated."); + } +} +async function digest(path: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} +async function canonicalPath(path: string): Promise { + if (await optionalStat(path)) return realpath(path); + return join(await realpath(dirname(path)), relative(dirname(path), path)); +} +async function assertClaimed(target: string, id: string, names: string[]): Promise { + const owner = join(target, OWNER_FILE); + if (!(await lstat(owner)).isFile() || (await lstat(owner)).isSymbolicLink()) throw new Error("Invalid migration ownership marker."); + const marker: unknown = JSON.parse(await readFile(owner, "utf8")); + if (!marker || typeof marker !== "object" || !("id" in marker) || marker.id !== id + || names.some((name) => ![OWNER_FILE, "data", "browser"].includes(name))) throw new Error("Destination contains unrelated data; select an empty directory."); +} +function relocatedLink(entry: Entry, mappings: Array<[string, string]>): string { + const link = entry.link ?? ""; + const absolute = resolve(dirname(entry.from), link); + for (const [from, to] of mappings) if (contains(from, absolute)) { + const next = join(to, relative(from, absolute)); + return isAbsolute(link) ? next : relative(dirname(entry.to), next); + } + // A relative external link also needs adjustment because its parent moved. + return isAbsolute(link) ? link : relative(dirname(entry.to), absolute); +} + +/** Cold copy only: no database, browser, plugin, or log writer may be running. */ +export async function migrateFiles(input: { + source: StorageRoots; target: string; anchor: string; id: string; + progress: (value: StorageProgress) => void; + relocate: (oldRoot: string, newRoot: string) => Promise; +}): Promise { + const { source, anchor, id, progress, relocate } = input; + const requestedTarget = resolve(input.target); + const target = await realpath(requestedTarget); + if (target !== requestedTarget) throw new Error("Destination directory changed identity."); + const owner = join(target, OWNER_FILE); + const names = await readdir(target); + if (names.length) { + // An interrupted copy can be retried only inside the directory claimed by this exact job. + await assertClaimed(target, id, names); + await assertSeparate(target, source, anchor); + await rm(join(target, "data"), { recursive: true, force: true }); + await rm(join(target, "browser"), { recursive: true, force: true }); + } else { + await validateTarget(target, source, anchor); + await writeFile(owner, JSON.stringify({ id }), { flag: "wx", mode: 0o600 }); + } + const next = { data: join(target, "data"), browser: join(target, "browser") }; + const entries: Entry[] = []; + const state: StorageProgress = { stage: "scanning", completedBytes: 0, totalBytes: 0, completedFiles: 0, totalFiles: 0 }; + progress({ ...state }); + if (await optionalStat(source.data)) await inventory(await realpath(source.data), next.data, entries); + else entries.push({ from: source.data, to: next.data, kind: "directory", bytes: 0, mode: 0o700 }); + await inventory(await realpath(source.browser), next.browser, entries, true); + state.totalBytes = entries.reduce((sum, entry) => sum + entry.bytes, 0); + state.totalFiles = entries.filter((entry) => entry.kind !== "directory").length; + const space = await statfs(target); + if (space.bavail * space.bsize < state.totalBytes + 16 * 1024 * 1024) throw new Error("Not enough free space in the destination directory."); + const mappings: Array<[string, string]> = [[source.data, next.data], [source.browser, next.browser], + [await canonicalPath(source.data), next.data], [await canonicalPath(source.browser), next.browser]]; + const hashes = new Map(); + state.stage = "copying"; + for (const entry of entries) { + if (entry.kind === "directory") await mkdir(entry.to, { recursive: false, mode: 0o700 }); + else if (entry.kind === "link") { + const kind = process.platform === "win32" && (await stat(entry.from)).isDirectory() ? "junction" : undefined; + await symlink(relocatedLink(entry, mappings), entry.to, kind); + state.completedFiles++; + } else { + const hash = createHash("sha256"); + await pipeline(createReadStream(entry.from), new Transform({ + transform(chunk: Buffer, _encoding, callback) { + hash.update(chunk); + state.completedBytes += chunk.length; + progress({ ...state }); + callback(null, chunk); + }, + }), createWriteStream(entry.to, { flags: "wx", mode: 0o600 })); + hashes.set(entry.from, hash.digest("hex")); + const file = await open(entry.to, "r+"); + try { await file.sync(); } finally { await file.close(); } + await chmod(entry.to, entry.mode & 0o777); + state.completedFiles++; + } + progress({ ...state }); + } + state.stage = "verifying"; state.completedBytes = 0; state.completedFiles = 0; + progress({ ...state }); + for (const entry of entries) { + if (entry.kind === "file") { + const hash = hashes.get(entry.from); + if (await digest(entry.to) !== hash || await digest(entry.from) !== hash) throw new Error("A storage file changed or failed verification; the original directory is still active."); + state.completedBytes += entry.bytes; state.completedFiles++; + } else if (entry.kind === "link") { + if (await readlink(entry.to) !== relocatedLink(entry, mappings)) throw new Error("A symbolic link failed verification."); + state.completedFiles++; + } + progress({ ...state }); + } + state.stage = "relocating"; progress({ ...state }); + if (await optionalStat(source.data)) await relocate(source.data, next.data); + // Restore permissions only after writing and verifying all descendants. + for (const entry of entries.reverse()) if (entry.kind === "directory") await chmod(entry.to, entry.mode & 0o777); + state.stage = "complete"; progress({ ...state }); + return next; +} +async function assertSeparate(target: string, source: StorageRoots, anchor: string) { + const canonical = await realpath(target); + if (canonical !== resolve(target)) throw new Error("Destination directory changed identity."); + for (const path of [source.data, source.browser, anchor]) { + const root = await canonicalPath(path); + if (contains(root, canonical) || contains(canonical, root)) throw new Error("Unsafe overlapping storage directories."); + } +} + +async function safeCachePaths(roots: StorageRoots): Promise { + const candidates = DATA_CACHE_PATHS.map((path) => join(roots.data, path)); + candidates.push(...BROWSER_CACHE_PATHS.map((path) => join(roots.browser, path))); + const partitions = join(roots.browser, "Partitions"); + if ((await optionalStat(partitions))?.isDirectory() && !(await lstat(partitions)).isSymbolicLink()) { + for (const name of await readdir(partitions)) candidates.push(...BROWSER_CACHE_PATHS.map((path) => join(partitions, name, path))); + } + const result: string[] = []; + for (const candidate of candidates) { + const root = contains(roots.data, candidate) ? roots.data : roots.browser; + if (!(await optionalStat(candidate))) continue; + // Canonical containment also checks every intermediate directory, including plugin/partition links. + const canonicalRoot = await realpath(root); + const parent = await realpath(dirname(candidate)); + const info = await lstat(candidate); + const expectedParent = join(canonicalRoot, relative(root, dirname(candidate))); + if (parent !== expectedParent || !contains(canonicalRoot, parent) || info.isSymbolicLink()) continue; + result.push(candidate); + } + return result; +} +export async function cacheSize(roots: StorageRoots): Promise { + let bytes = 0; + for (const path of await safeCachePaths(roots)) { + const entries: Entry[] = []; await inventory(path, path, entries); + bytes += entries.reduce((sum, entry) => sum + entry.bytes, 0); + } + return bytes; +} +export async function clearCaches(roots: StorageRoots): Promise { + for (const path of await safeCachePaths(roots)) await rm(path, { recursive: true, force: true }); +} +export async function removeBackups(backups: StorageRoots[], active: StorageRoots, anchor: string): Promise { + const canonicalActive = [await canonicalPath(active.data), await canonicalPath(active.browser)]; + const canonicalAnchor = await realpath(anchor); + const targets: Array<{ path: string; anchor: boolean }> = []; + // Preflight the entire plan before deleting a single entry. + for (const backup of backups) for (const path of [backup.data, backup.browser]) { + if (!(await optionalStat(path))) continue; + const canonical = await realpath(path); + if (canonicalActive.some((root) => contains(canonical, root) || contains(root, canonical))) throw new Error("Backup overlaps active storage."); + if (canonical !== canonicalAnchor && contains(canonical, canonicalAnchor)) throw new Error("Backup overlaps installation preferences."); + targets.push({ path, anchor: canonical === canonicalAnchor }); + } + for (const target of targets) { + if (target.anchor) { + // Keep the stable installation lock and bootstrap preference when removing the original Chromium profile. + for (const name of await readdir(target.path)) if (!excluded(name)) await rm(join(target.path, name), { recursive: true, force: true }); + } else await rm(target.path, { recursive: true, force: true }); + } +} diff --git a/apps/desktop/electron/main/storage/ipc.ts b/apps/desktop/electron/main/storage/ipc.ts new file mode 100644 index 0000000000..c75c4a87e7 --- /dev/null +++ b/apps/desktop/electron/main/storage/ipc.ts @@ -0,0 +1,68 @@ +import { app, dialog } from "electron"; +import { randomUUID } from "node:crypto"; +import { IPC, type StorageInfo } from "@pi-desktop/shared"; +import type { IpcRegistrar } from "../ipc/types"; +import type { BrowserWindow } from "electron"; +import { cacheSize, validateTarget } from "./files"; +import { getStorageBootstrap } from "./bootstrap"; +import { writeStoragePreferences, type StorageJob } from "./preferences"; + +type Dependencies = { registrar: IpcRegistrar; getMainWindow: () => BrowserWindow | null; restart: () => void }; +export function registerStorageIpc({ registrar, getMainWindow, restart }: Dependencies): void { + let chosen: string | null = null; + let busy = false; + const language = (input: unknown): string => { + if (!input || typeof input !== "object" || !("language" in input) || typeof input.language !== "string" || input.language.length > 32) throw new Error("Invalid storage operation language."); + return input.language; + }; + const requireManaged = () => { + const state = getStorageBootstrap(); + if (!state.managed) throw new Error("Storage is controlled by PI_DESKTOP_DATA_DIR."); + if (busy || state.preferences.pending) throw new Error("A storage operation is already pending."); + return state; + }; + const schedule = (job: StorageJob) => { + const state = getStorageBootstrap(); + const preferences = { ...state.preferences, pending: job, lastError: undefined }; + writeStoragePreferences(state.file, preferences); + state.preferences = preferences; + restart(); + }; + registrar.handleWithEvent(IPC.invoke.storageGet, async (event): Promise => { + registrar.assertMainWindowSender(event); + const state = getStorageBootstrap(); + return { dataPath: state.preferences.roots.data, browserPath: state.preferences.roots.browser, + managed: state.managed, cacheBytes: await cacheSize(state.preferences.roots), + pendingPath: state.preferences.pending?.target ?? null, lastError: state.preferences.lastError ?? null, + backupPaths: state.preferences.backups.flatMap((roots) => [roots.data, roots.browser]) }; + }); + registrar.handleWithEvent(IPC.invoke.storageChoose, async (event) => { + registrar.assertMainWindowSender(event); + requireManaged(); + const window = getMainWindow(); + if (!window) throw new Error("Main window unavailable."); + const result = await dialog.showOpenDialog(window, { properties: ["openDirectory", "createDirectory"] }); + chosen = result.canceled ? null : result.filePaths[0] ?? null; + return chosen; + }); + registrar.handleWithEvent(IPC.invoke.storageMigrate, async (event, input: unknown) => { + registrar.assertMainWindowSender(event); + const state = requireManaged(); + if (!input || typeof input !== "object" || !("path" in input) || typeof input.path !== "string" || input.path !== chosen) throw new Error("Select the destination with the directory picker first."); + busy = true; + try { + const failed = state.preferences.failedMigration; + const retryId = failed?.target === input.path ? failed.id : undefined; + const target = await validateTarget(input.path, state.preferences.roots, state.anchor, retryId); + schedule({ id: retryId ?? randomUUID(), kind: "migrate", target, language: language(input) }); + } finally { busy = false; } + }); + for (const [channel, kind] of [[IPC.invoke.storageClearCache, "cache"], [IPC.invoke.storageRemoveBackup, "backup"]] as const) { + registrar.handleWithEvent(channel, async (event, input: unknown) => { + registrar.assertMainWindowSender(event); + const state = requireManaged(); + if (kind === "backup" && !state.preferences.backups.length) throw new Error("No old storage backup exists."); + schedule({ id: randomUUID(), kind, language: language(input) }); + }); + } +} diff --git a/apps/desktop/electron/main/storage/preferences.ts b/apps/desktop/electron/main/storage/preferences.ts new file mode 100644 index 0000000000..fca3af529e --- /dev/null +++ b/apps/desktop/electron/main/storage/preferences.ts @@ -0,0 +1,61 @@ +import { mkdirSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { randomUUID } from "node:crypto"; + +export const STORAGE_PREFERENCE_FILE = "storage-location.json"; +export type StorageRoots = { data: string; browser: string }; +export type StorageJob = { id: string; kind: "migrate" | "cache" | "backup"; target?: string; language: string }; +export type StoragePreferences = { + version: 1; + roots: StorageRoots; + backups: StorageRoots[]; + pending?: StorageJob; + lastError?: string; + failedMigration?: StorageJob; +}; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function roots(value: unknown): value is StorageRoots { + return record(value) && typeof value.data === "string" && isAbsolute(value.data) + && typeof value.browser === "string" && isAbsolute(value.browser); +} +export function readStoragePreferences(file: string, defaults: StorageRoots): StoragePreferences { + let raw: string; + try { raw = readFileSync(file, "utf8"); } + catch (error) { + if (record(error) && error.code === "ENOENT") return { version: 1, roots: defaults, backups: [] }; + throw error; + } + const value: unknown = JSON.parse(raw); + if (!record(value) || value.version !== 1 || !roots(value.roots) + || !Array.isArray(value.backups) || !value.backups.every(roots) + || (value.lastError !== undefined && typeof value.lastError !== "string")) { + throw new Error("Invalid storage preferences. Restore storage-location.json before starting."); + } + for (const job of [value.pending, value.failedMigration]) { + if (job === undefined) continue; + if (!record(job) || typeof job.id !== "string" || !/^[a-f0-9-]{36}$/.test(job.id) + || !["migrate", "cache", "backup"].includes(String(job.kind)) + || typeof job.language !== "string" + || (job.kind === "migrate" && (typeof job.target !== "string" || !isAbsolute(job.target)))) { + throw new Error("Invalid pending storage operation."); + } + } + return value as StoragePreferences; +} + +/** Flush the new pointer before publishing it. The previous pointer survives a failed write. */ +export function writeStoragePreferences(file: string, value: StoragePreferences): void { + mkdirSync(dirname(file), { recursive: true }); + const temporary = join(dirname(file), `.storage-${randomUUID()}.tmp`); + const descriptor = openSync(temporary, "wx", 0o600); + try { writeFileSync(descriptor, JSON.stringify(value)); fsyncSync(descriptor); } + finally { closeSync(descriptor); } + renameSync(temporary, file); + if (process.platform !== "win32") { + const directory = openSync(dirname(file), "r"); + try { fsyncSync(directory); } finally { closeSync(directory); } + } +} diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index d3bff16696..259980777e 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -63,6 +63,7 @@ import { PromptEnhancementCard } from "./prompt-enhancement-card"; import { CloseBehaviorSection, DeveloperSection } from "./developer-sections"; import { PluginScenicThemesDestination } from "../../components/settings/PluginScenicThemesDestination"; import { ConfigSyncPage } from "../../components/settings/ConfigSyncPage"; +import { StorageSettingsSection } from "./StorageSettingsSection"; type SettingsTab = ReturnType["settingsTab"]; @@ -420,6 +421,8 @@ export function SettingsPage() { + + 0 ? Math.min(4, Math.floor(Math.log(size) / Math.log(1024))) : 0; + return `${new Intl.NumberFormat(language, { maximumFractionDigits: 1 }).format(size / 1024 ** index)} ${units[index]}`; +} + +/** Storage changes are completed by Main's offline restart workflow. */ +export function StorageSettingsSection() { + const { t, i18n } = useTranslation(); + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [destination, setDestination] = useState(null); + const [confirmation, setConfirmation] = useState(null); + const [busy, setBusy] = useState(false); + const mounted = useRef(false); + const operation = useRef(false); + const confirmationRef = useRef(null); + const language = i18n.resolvedLanguage ?? i18n.language ?? "en"; + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const next = await api.getStorageInfo(); + if (mounted.current) setInfo(next); + } catch (cause) { + if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (mounted.current) setLoading(false); + } + }, []); + + useEffect(() => { + mounted.current = true; + void refresh(); + return () => { mounted.current = false; }; + }, [refresh]); + + useEffect(() => { + if (confirmation) { + confirmationRef.current?.focus({ preventScroll: true }); + confirmationRef.current?.scrollIntoView({ block: "nearest" }); + } + }, [confirmation]); + + const disabled = loading || busy || !info?.managed || Boolean(info.pendingPath); + const choose = async () => { + if (disabled || operation.current) return; + operation.current = true; + setBusy(true); + setError(null); + try { + const path = await api.chooseStorageDirectory(); + if (mounted.current && path) { + setDestination(path); + setConfirmation("migrate"); + } + } catch (cause) { + if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + operation.current = false; + if (mounted.current) setBusy(false); + } + }; + + const confirm = async () => { + if (!confirmation || disabled || operation.current) return; + operation.current = true; + setBusy(true); + setError(null); + try { + if (confirmation === "migrate" && destination) { + await api.migrateStorage({ path: destination, language }); + } else if (confirmation === "cache") { + await api.clearStorageCache({ language }); + } else if (confirmation === "backup") { + await api.removeStorageBackup({ language }); + } + } catch (cause) { + operation.current = false; + if (mounted.current) { + setError(cause instanceof Error ? cause.message : String(cause)); + setBusy(false); + } + } + }; + + const confirmTitle = confirmation === "migrate" + ? t("settings.storage.migrateTitle") + : confirmation === "cache" + ? t("settings.storage.cacheTitle") + : t("settings.storage.backupTitle"); + + return ( +
+ + {info ? <> + {info.dataPath}} + > + + + + + + {info.backupPaths.length > 0 ?
{path}
)} + > + +
: null} + {!info.managed ?

{t("settings.storage.environmentManaged")}

: null} + {info.pendingPath ?

{t("settings.storage.pending", { path: info.pendingPath })}

: null} + {info.lastError ?

{t("settings.storage.lastError", { error: info.lastError })}

: null} + : + {loading ? {t("common.loading")} : } + } + {confirmation && info ?
+

{confirmTitle}

+ {confirmation === "migrate" ? <> +
+
{t("settings.storage.source")}
{info.dataPath}
+ {info.browserPath !== info.dataPath ? <> +
{t("settings.storage.browserSource")}
{info.browserPath}
+ : null} +
{t("settings.storage.destination")}
{destination}
+
+

{t("settings.storage.migrateHint")}

+

{t("settings.storage.scope")}

+ :

{confirmation === "cache" ? t("settings.storage.cacheHint", { size: formatSize(info.cacheBytes, language) }) : t("settings.storage.backupHint")}

} +
+ + +
+
: null} + {error ?

{t("settings.storage.operationError", { error })}

: null} + {busy && !confirmation ?

{t("common.loading")}

: null} +
+
+ ); +} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 0e2277558d..25a0868852 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -122,6 +122,7 @@ import type { TrustedExtensionUiPrompt, TrustedExtensionUiPromptResponse, SessionTodoSnapshot, + StorageInfo, } from "@pi-desktop/shared"; import { defaultCommandShellForPlatform, @@ -637,6 +638,14 @@ export const api = { runImportModelConfigs: (items: ModelConfigImportCandidate[]) => invoke(IPC.invoke.modelConfigImportRun, items), getSettings: () => invoke(IPC.invoke.settingsGet).then(normalizeSettings), + getStorageInfo: () => invoke(IPC.invoke.storageGet), + chooseStorageDirectory: () => invoke(IPC.invoke.storageChoose), + migrateStorage: (input: { path: string; language: string }) => + invoke(IPC.invoke.storageMigrate, input), + clearStorageCache: (input: { language: string }) => + invoke(IPC.invoke.storageClearCache, input), + removeStorageBackup: (input: { language: string }) => + invoke(IPC.invoke.storageRemoveBackup, input), setSettings: (settings: AppSettings) => invoke(IPC.invoke.settingsSet, validateSettingsWrite(settings)), configSyncGetState: () => invoke(IPC.invoke.configSyncGetState), diff --git a/apps/desktop/src/lib/settings-search.ts b/apps/desktop/src/lib/settings-search.ts index 5099ce4fa1..a2363c0883 100644 --- a/apps/desktop/src/lib/settings-search.ts +++ b/apps/desktop/src/lib/settings-search.ts @@ -63,6 +63,11 @@ export const SETTINGS_NAV: SettingsNavEntry[] = [ group: "preferences", keywordKeys: [ "settings.appearance", + "settings.storage.title", + "settings.storage.dataPath", + "settings.storage.cache", + "settings.storage.clearCache", + "settings.storage.backup", "settings.theme", "settings.language", "settings.languageAuto", diff --git a/apps/desktop/src/styles/settings.css b/apps/desktop/src/styles/settings.css index 3084755d7f..25ac858ab4 100644 --- a/apps/desktop/src/styles/settings.css +++ b/apps/desktop/src/styles/settings.css @@ -530,6 +530,64 @@ color: var(--ds-danger); } +.settings-storage-path, +.settings-storage-message, +.settings-storage-confirmation dd { + overflow-wrap: anywhere; +} + +.settings-storage-message { + margin: 12px 16px; + color: var(--ds-text-secondary); + font-size: var(--text-sm); + line-height: var(--leading-body); +} + +.settings-storage-message.error { + color: var(--ds-danger); +} + +.settings-storage-confirmation { + margin: 12px 16px 16px; + padding: 16px; + border: 1px solid var(--ds-border-default); + border-radius: var(--radius-md); + color: var(--ds-text-secondary); + font-size: var(--text-sm); + line-height: var(--leading-body); +} + +.settings-storage-confirmation h4 { + margin: 0 0 12px; + color: var(--ds-text-primary); + font-size: var(--text-md); + font-weight: var(--font-weight-medium); +} + +.settings-storage-confirmation p { + margin: 8px 0; +} + +.settings-storage-confirmation dl { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px 16px; + margin: 0 0 12px; +} + +.settings-storage-confirmation dd { + margin: 0; + color: var(--ds-text-primary); +} + +.settings-storage-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + .settings-config-sync-refresh { display: flex; align-items: center; diff --git a/apps/desktop/test/development-branding.test.mjs b/apps/desktop/test/development-branding.test.mjs index ceafc25f31..a2c496fa95 100644 --- a/apps/desktop/test/development-branding.test.mjs +++ b/apps/desktop/test/development-branding.test.mjs @@ -17,7 +17,7 @@ const devScriptUrl = new URL( ); const mainSource = await readMainSource(); -const mainIndexSource = await readMainModule("index.ts"); +const installationSource = await readMainModule("installation.ts"); const brandingSource = await readMainModule("bootstrap/app-lifecycle.ts"); const windowSource = await readMainModule("bootstrap/window.ts"); const startupSource = await readMainModule("bootstrap/startup.ts"); @@ -44,9 +44,9 @@ test("Windows runtime registers the canonical native application identity", () = const appId = protocolSource.match(/APP_ID = "([^"]+)"/)?.[1]; assert.equal(appId, packageJson.build.appId); assert.ok(startupSource.includes("app.whenReady()"), "main process readiness hook"); - assert.match(mainIndexSource, /app\.setName\(APP_NAME\)/); + assert.match(installationSource, /app\.setName\(APP_NAME\)/); assert.match( - mainIndexSource, + installationSource, /process\.platform === "win32"[\s\S]*app\.setAppUserModelId\(APP_ID\)/, ); }); diff --git a/apps/desktop/test/development-profile.test.mjs b/apps/desktop/test/development-profile.test.mjs index 4e2bdd3373..1abc065070 100644 --- a/apps/desktop/test/development-profile.test.mjs +++ b/apps/desktop/test/development-profile.test.mjs @@ -18,6 +18,8 @@ const { } = await import("../electron/main/data-paths.ts"); const indexSource = await readMainModule("index.ts"); +const installationSource = await readMainModule("installation.ts"); +const entrySource = await readMainModule("entry.ts"); test("a development build owns a different data directory than the shipped app", () => { const home = join(tmpdir(), "pi-desktop-profile-home"); @@ -98,9 +100,9 @@ test("a development build takes its own userData before the single-instance lock // Electron asks for it; otherwise a running packaged app refuses the lock and // `pnpm dev` quits on arrival. const pathsSource = await readMainModule("data-paths.ts"); - const apply = indexSource.indexOf("applyDevelopmentUserData(app, isDevelopmentBuild)"); - const setName = indexSource.indexOf("app.setName(APP_NAME)"); - const lock = indexSource.indexOf("app.requestSingleInstanceLock()"); + const apply = installationSource.indexOf("applyDevelopmentUserData(app, isDevelopmentBuild)"); + const setName = installationSource.indexOf("app.setName(APP_NAME)"); + const lock = installationSource.indexOf("app.requestSingleInstanceLock()"); assert.ok(apply > 0, "main must give the development build its own userData"); assert.ok(lock > 0, "main must request the single-instance lock"); @@ -121,14 +123,17 @@ test("a development build takes its own userData before the single-instance lock // The two profiles are told apart by the same verdict everywhere, and it is // reached before the name the lock path derives from. - const development = indexSource.search( + const development = installationSource.search( /const isDevelopmentBuild =\s*\n?\s*process\.env\.PI_DESKTOP_DEV === "1" \|\| !app\.isPackaged;/, ); assert.ok(development > 0 && development < apply); }); test("main resolves one data directory and publishes it to everything below", () => { - assert.match(indexSource, /const dataDir = desktopDataDir\(isDevelopmentBuild\);/); + assert.match(installationSource, /const defaultDataDir = desktopDataDir\(isDevelopmentBuild\);/); + assert.match(entrySource, /prepareStorage\(defaultDataDir, !singleInstanceRequired\)/); + assert.match(indexSource, /const storage = getStorageBootstrap\(\);/); + assert.match(indexSource, /const dataDir = storage\.preferences\.roots\.data;/); // The plugin runtime resolves this root from the environment rather than // taking it as a parameter, so the resolved value has to be the one it reads. assert.match(indexSource, /process\.env\.PI_DESKTOP_DATA_DIR = dataDir;/); @@ -137,13 +142,13 @@ test("main resolves one data directory and publishes it to everything below", () // Publishing happens after the lock verdict, which reads the same variable: // moving the write above `singleInstanceRequired` would make every launch // look like it had been given an explicit data directory and skip the lock. - const lockVerdict = indexSource.indexOf( + const lockVerdict = installationSource.indexOf( "const singleInstanceRequired = !process.env.PI_DESKTOP_DATA_DIR;", ); assert.ok(lockVerdict > 0); - assert.ok( - indexSource.indexOf("process.env.PI_DESKTOP_DATA_DIR = dataDir;") > lockVerdict, - ); + assert.doesNotMatch(installationSource, /process\.env\.PI_DESKTOP_DATA_DIR = dataDir;/); + assert.match(entrySource, /from ["']\.\/installation["']/); + assert.match(entrySource, /\.then\([\s\S]*?import\(["']\.\/index["']\)/); }); test("downstream data directories follow the profile instead of the shipped default", async () => { diff --git a/apps/desktop/test/main-process-errors.test.mjs b/apps/desktop/test/main-process-errors.test.mjs index e7deaf2344..0df9c5e016 100644 --- a/apps/desktop/test/main-process-errors.test.mjs +++ b/apps/desktop/test/main-process-errors.test.mjs @@ -96,20 +96,24 @@ test("installMainProcessErrorHandlers is idempotent", () => { assert.equal(process.listenerCount("unhandledRejection"), afterFirstRej); }); -test("Electron main installs handlers and does not use Electron's default dialog path", async () => { +test("Electron startup installs handlers before boot and wires the logger", async () => { const index = await readFile( new URL("../electron/main/index.ts", import.meta.url), "utf8", ); - assert.match(index, /installMainProcessErrorHandlers\(\)/); + const installation = await readFile( + new URL("../electron/main/installation.ts", import.meta.url), + "utf8", + ); + assert.match(installation, /installMainProcessErrorHandlers\(\)/); assert.match(index, /installMainProcessErrorHandlers\(\{/); assert.match(index, /emit:/); assert.doesNotMatch( - index, + `${installation}\n${index}`, /process\.on\("unhandledRejection"/, ); assert.doesNotMatch( - index, + `${installation}\n${index}`, /process\.on\("uncaughtException"/, ); }); diff --git a/apps/desktop/test/single-instance.test.mjs b/apps/desktop/test/single-instance.test.mjs index c8213fa25b..5bb9b0a687 100644 --- a/apps/desktop/test/single-instance.test.mjs +++ b/apps/desktop/test/single-instance.test.mjs @@ -1,13 +1,79 @@ import { readMainSource } from "./helpers/source-contracts.mjs"; import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; const mainSource = await readMainSource(); +const installationSource = await readFile(new URL("../electron/main/installation.ts", import.meta.url), "utf8"); +const entrySource = await readFile(new URL("../electron/main/entry.ts", import.meta.url), "utf8"); const activationSource = await readFile( new URL("../electron/main/bootstrap/app-activation.ts", import.meta.url), "utf8", ); +const require = createRequire(import.meta.url); +const { build } = createRequire(new URL("../../../packages/agent-runtime/package.json", import.meta.url))("esbuild"); +const bundledEntry = await build({ + stdin: { contents: 'export * from "./installation"; import "./entry";', resolveDir: fileURLToPath(new URL("../electron/main/", import.meta.url)), loader: "ts" }, + // A CJS build also rejects an accidentally reintroduced top-level await. + bundle: true, platform: "node", format: "cjs", write: false, + plugins: [{ name: "installation-external-boundaries", setup(builder) { + builder.onResolve({ filter: /^@pi-desktop\/shared$/ }, () => ({ path: fileURLToPath(new URL("../../../packages/shared/src/protocol.ts", import.meta.url)) })); + builder.onResolve({ filter: /^electron$|^\.\/(logger|main-process-errors|index|storage\/bootstrap)$/ }, (args) => ({ path: args.path, namespace: "installation-test-boundary" })); + builder.onLoad({ filter: /.*/, namespace: "installation-test-boundary" }, (args) => { + if (args.path === "electron") return { contents: "export const app = globalThis.__installationHarness.app; export class BrowserWindow { static getAllWindows() { return []; } }" }; + if (args.path === "./logger") return { contents: "export function ignoreBrokenStdio() {}" }; + if (args.path === "./main-process-errors") return { contents: "export function installMainProcessErrorHandlers() {}" }; + if (args.path === "./storage/bootstrap") return { contents: "export function prepareStorage(root, override) { return globalThis.__installationHarness.prepareStorage(root, override); }" }; + return { contents: "globalThis.__installationHarness.bootCount++;" }; + }); + } }], +}); + +async function launchInstallation(t, { override, development = false, managed = false, ownsLock = true } = {}) { + const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); + const originalArgv = process.argv; + const originalEnvironment = { data: process.env.PI_DESKTOP_DATA_DIR, development: process.env.PI_DESKTOP_DEV }; + t.after(async () => { + process.argv = originalArgv; + for (const [key, value] of [["PI_DESKTOP_DATA_DIR", originalEnvironment.data], ["PI_DESKTOP_DEV", originalEnvironment.development]]) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + delete globalThis.__installationHarness; + await rm(root, { recursive: true, force: true }); + }); + if (override === undefined) delete process.env.PI_DESKTOP_DATA_DIR; + else process.env.PI_DESKTOP_DATA_DIR = override; + delete process.env.PI_DESKTOP_DEV; + process.argv = [originalArgv[0], "installation-test", ...(managed ? ["--pi-managed-storage"] : [])]; + const calls = []; const storageCalls = []; + let finishStorage; + const storageReady = new Promise((resolve) => { finishStorage = resolve; }); + const harness = { + bootCount: 0, + app: { + isPackaged: !development, + setName: (name) => calls.push(["name", name]), + getPath: () => join(root, "appData"), + setPath: (...args) => calls.push(["path", ...args]), + setAppUserModelId: () => {}, + requestSingleInstanceLock: () => { calls.push(["lock"]); return ownsLock; }, + quit: () => calls.push(["quit"]), + exit: (code) => calls.push(["exit", code]), + on: () => {}, + commandLine: { hasSwitch: () => false, appendSwitch: () => {} }, + }, + prepareStorage: (...args) => { storageCalls.push(args); return storageReady; }, + }; + globalThis.__installationHarness = harness; + const entry = join(root, "entry.cjs"); + await writeFile(entry, bundledEntry.outputFiles[0].text); + const installation = require(entry); + return { installation, calls, storageCalls, harness, finishStorage }; +} test("the single-instance lock is taken before anything touches the data directory", () => { // Electron keeps the lock under `userData`, which is derived from the app @@ -15,18 +81,18 @@ test("the single-instance lock is taken before anything touches the data directo // writes into the data directory of whichever instance is already running: // the logger creates the log tree, the outbox loads and rewrites the queued // appends. A duplicate launch must be gone before either happens. - const lock = mainSource.indexOf("app.requestSingleInstanceLock()"); + const lock = installationSource.indexOf("app.requestSingleInstanceLock()"); assert.ok(lock > 0, "main must request the single-instance lock"); - assert.ok(mainSource.indexOf("app.setName(APP_NAME)") < lock); - assert.ok(lock < mainSource.indexOf("new Logger(")); - assert.ok(lock < mainSource.indexOf("new PersistenceOutbox(")); + assert.ok(installationSource.indexOf("app.setName(APP_NAME)") < lock); + assert.doesNotMatch(installationSource, /new Logger\(|new PersistenceOutbox\(|prepareStorage\(/); + assert.match(entrySource, /from ["']\.\/installation["']/); + assert.match(entrySource, /if \(hasSingleInstanceLock\)/); + assert.match(entrySource, /prepareStorage\([\s\S]*?\.then\([\s\S]*?import\(["']\.\/index["']\)/); }); test("a launch that loses the lock quits and boots nothing", () => { - const guard = mainSource.slice( - mainSource.indexOf("if (!hasSingleInstanceLock) {"), - ); - assert.match(guard.slice(0, guard.indexOf("\n}\n") + 2), /app\.quit\(\)/); + assert.match(installationSource, /if \(!hasSingleInstanceLock\) app\.quit\(\);/); + assert.match(entrySource, /if \(hasSingleInstanceLock\)/); // `app.quit()` before readiness is not guaranteed to preempt `ready`, so the // boot path refuses to run a second window, tray, host, or sidecar on top of @@ -62,11 +128,53 @@ test("a run with its own data directory keeps the current start behavior", () => // data directory, share no database, outbox, or logs with the default // installation, and have to stay launchable while one is running. assert.match( - mainSource, + installationSource, /const singleInstanceRequired = !process\.env\.PI_DESKTOP_DATA_DIR;/, ); assert.match( - mainSource, + installationSource, /const hasSingleInstanceLock = singleInstanceRequired\s*\n?\s*\? app\.requestSingleInstanceLock\(\)\s*\n?\s*: true;/, ); }); + +test("managed relaunch clears the published root before locking and scheduling storage", { timeout: 60_000 }, async (t) => { + const value = await launchInstallation(t, { override: join(tmpdir(), "previous-published-root"), managed: true }); + assert.equal(process.env.PI_DESKTOP_DATA_DIR, undefined); + assert.equal(value.installation.singleInstanceRequired, true); + assert.equal(value.installation.hasSingleInstanceLock, true); + assert.equal(value.calls.filter(([kind]) => kind === "lock").length, 1); + assert.deepEqual(value.storageCalls, [[value.installation.defaultDataDir, false]]); + assert.equal(value.harness.bootCount, 0, "runtime must wait for storage preparation"); + value.finishStorage(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(value.harness.bootCount, 1); +}); + +test("explicit profile overrides still skip installation locking", { timeout: 60_000 }, async (t) => { + const override = join(tmpdir(), "explicit-installation-profile"); + const value = await launchInstallation(t, { override, ownsLock: false }); + assert.equal(process.env.PI_DESKTOP_DATA_DIR, override); + assert.equal(value.installation.singleInstanceRequired, false); + assert.equal(value.installation.hasSingleInstanceLock, true); + assert.equal(value.installation.defaultDataDir, override); + assert.equal(value.calls.some(([kind]) => kind === "lock" || kind === "quit"), false); + assert.deepEqual(value.storageCalls, [[override, true]]); +}); + +test("a duplicate launch never initializes storage or imports the full runtime", { timeout: 60_000 }, async (t) => { + const value = await launchInstallation(t, { ownsLock: false }); + assert.equal(value.installation.hasSingleInstanceLock, false); + assert.equal(value.calls.some(([kind]) => kind === "quit"), true); + assert.deepEqual(value.storageCalls, []); + assert.equal(value.harness.bootCount, 0); +}); + +test("development installation applies its separate profile before requesting the lock", { timeout: 60_000 }, async (t) => { + const value = await launchInstallation(t, { development: true }); + assert.equal(value.installation.isDevelopmentBuild, true); + const pathIndex = value.calls.findIndex(([kind, path]) => kind === "path" && path === "userData"); + const lockIndex = value.calls.findIndex(([kind]) => kind === "lock"); + assert.ok(pathIndex > 0 && pathIndex < lockIndex); + assert.equal(value.calls[pathIndex][2].endsWith("PI-Desktop Dev"), true); + assert.equal(value.installation.defaultDataDir.endsWith(".pi-desktop-dev"), true); +}); diff --git a/apps/desktop/test/storage-maintenance.test.mjs b/apps/desktop/test/storage-maintenance.test.mjs new file mode 100644 index 0000000000..9ccbbb900a --- /dev/null +++ b/apps/desktop/test/storage-maintenance.test.mjs @@ -0,0 +1,409 @@ +import assert from "node:assert/strict"; +import { createHash, randomUUID } from "node:crypto"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test, { after } from "node:test"; + +const { build } = createRequire(new URL("../../../packages/agent-runtime/package.json", import.meta.url))("esbuild"); +const storageDirectory = fileURLToPath(new URL("../electron/main/storage/", import.meta.url)); +const result = await build({ + stdin: { contents: ['files', 'preferences', 'bootstrap', 'ipc'].map((name) => `export * from "./${name}";`).join("\n") + '\nexport { IPC } from "@pi-desktop/shared";\nexport { catalogs } from "@pi-desktop/i18n";', resolveDir: storageDirectory, loader: "ts" }, + bundle: true, platform: "node", format: "esm", write: false, + plugins: [{ name: "external-storage-boundaries", setup(builder) { + builder.onResolve({ filter: /^@pi-desktop\/shared$/ }, () => ({ path: fileURLToPath(new URL("../../../packages/shared/src/protocol.ts", import.meta.url)) })); + builder.onResolve({ filter: /^@pi-desktop\/i18n$/ }, () => ({ path: fileURLToPath(new URL("../../../packages/i18n/src/index.ts", import.meta.url)) })); + builder.onResolve({ filter: /^(electron|node:child_process)$|host-process$/ }, (args) => ({ path: args.path, namespace: "storage-test-boundary" })); + builder.onLoad({ filter: /.*/, namespace: "storage-test-boundary" }, (args) => { + if (args.path === "electron") return { contents: "export const app = globalThis.__storageHarness.app; export const dialog = globalThis.__storageHarness.dialog; export const BrowserWindow = globalThis.__storageHarness.BrowserWindow;" }; + if (args.path === "node:child_process") return { contents: "export function execFile(command, args, options, callback) { globalThis.__storageHarness.execFile(command, args, options, callback); }" }; + return { contents: "export function resolveHostBinary() { return '/fake/host-core'; }" }; + }); + } }], +}); + +const exitSignal = new Error("test process exited"); +const harness = { + anchor: "", selection: null, paths: [], windows: [], errors: [], restarts: 0, hostError: null, + app: { getPath: () => harness.anchor, getLocale: () => "en", setPath: (...value) => harness.paths.push(value), whenReady: async () => {}, relaunch: () => { harness.restarts++; }, exit: () => { throw exitSignal; } }, + dialog: { showOpenDialog: async () => ({ canceled: harness.selection === null, filePaths: [harness.selection] }), showMessageBox: async (...value) => { harness.errors.push(value); return { response: 0 }; } }, + BrowserWindow: class { + constructor(options) { this.options = options; this.scripts = []; harness.windows.push(this); this.webContents = { setWindowOpenHandler: () => {}, on: () => {}, executeJavaScript: async (script) => { this.scripts.push(script); } }; } + setMenu() {} + async loadURL(url) { this.url = url; } + isDestroyed() { return false; } + }, + execFile: (_command, _args, _options, callback) => callback(harness.hostError, "", ""), +}; +globalThis.__storageHarness = harness; +const bundleDirectory = await mkdtemp(join(tmpdir(), "pi-storage-bundle-")); +after(() => rm(bundleDirectory, { recursive: true, force: true })); +const bundleFile = join(bundleDirectory, "storage-maintenance.mjs"); +await writeFile(bundleFile, result.outputFiles[0].text); +const storage = await import(pathToFileURL(bundleFile).href); + +async function fixture(t, { dataMissing = false } = {}) { + const root = await realpath(await mkdtemp(join(tmpdir(), "pi-storage-test-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const roots = { data: join(root, "old-data"), browser: join(root, "old-browser") }; + const target = join(root, "destination"); + const anchor = roots.browser; + await Promise.all([mkdir(roots.browser), mkdir(target), ...(dataMissing ? [] : [mkdir(roots.data)])]); + return { root, roots, target, anchor }; +} +async function put(path, contents = "preserve this data", mode = 0o600) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, { mode }); + return path; +} +async function exists(path) { + try { await lstat(path); return true; } + catch (error) { if (error.code === "ENOENT") return false; throw error; } +} +const hash = (buffer) => createHash("sha256").update(buffer).digest("hex"); +const migrate = (value, overrides = {}) => storage.migrateFiles({ source: value.roots, target: value.target, anchor: value.anchor, id: randomUUID(), progress: () => {}, relocate: async () => {}, ...overrides }); +function resetHarness(anchor) { + harness.anchor = anchor; harness.paths = []; harness.windows = []; harness.errors = []; + harness.restarts = 0; harness.hostError = null; harness.selection = null; +} +function registerHandlers() { + const handlers = new Map(); + storage.registerStorageIpc({ registrar: { handleWithEvent: (channel, handler) => handlers.set(channel, handler), assertMainWindowSender: (event) => { if (event !== "main") throw new Error("untrusted sender"); } }, getMainWindow: () => ({}), restart: () => { harness.restarts++; } }); + return (channel, input, event = "main") => handlers.get(storage.IPC.invoke[channel])(event, input); +} + +test("cold migration copies real data, verifies hashes, preserves secret permissions and reports stages", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + const payload = Buffer.from(Array.from({ length: 8192 }, (_, index) => index % 251)); + await put(join(value.roots.data, "sessions/chat.jsonl"), payload); + await put(join(value.roots.data, "secrets.json"), "encrypted-test-secret", 0o600); + await chmod(value.roots.data, 0o700); + await put(join(value.roots.browser, "Local Storage/leveldb/test.ldb"), "browser state"); + await put(join(value.roots.browser, storage.STORAGE_PREFERENCE_FILE), "bootstrap pointer"); + await put(join(value.roots.browser, "SingletonLock"), "installation lock"); + const events = []; const relocations = []; + const next = await migrate(value, { progress: (event) => events.push(event), relocate: async (...roots) => relocations.push(roots) }); + assert.equal(hash(await readFile(join(next.data, "sessions/chat.jsonl"))), hash(payload)); + assert.equal((await lstat(join(next.data, "secrets.json"))).mode & 0o777, 0o600); + assert.equal((await lstat(next.data)).mode & 0o777, 0o700); + assert.equal(await readFile(join(next.browser, "Local Storage/leveldb/test.ldb"), "utf8"), "browser state"); + assert.equal(await exists(join(next.browser, storage.STORAGE_PREFERENCE_FILE)), false); + assert.equal(await exists(join(next.browser, "SingletonLock")), false); + assert.equal(await readFile(join(value.roots.data, "sessions/chat.jsonl")).then(hash), hash(payload)); + assert.deepEqual(relocations, [[value.roots.data, next.data]]); + assert.deepEqual([...new Set(events.map((event) => event.stage))], ["scanning", "copying", "verifying", "relocating", "complete"]); + assert.equal(events.at(-1).completedBytes, events.at(-1).totalBytes); + assert.equal(events.at(-1).completedFiles, events.at(-1).totalFiles); +}); + +test("migration detects changed copy content before relocating persistent paths", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); await put(join(value.roots.data, "secrets.json"), "original"); + let corrupted = false; let relocated = false; + await assert.rejects(migrate(value, { progress: (event) => { + if (event.stage === "verifying" && !corrupted) { + corrupted = true; + // Synchronous corruption provides an explicit verification boundary without sleeps. + createRequire(import.meta.url)("node:fs").writeFileSync(join(value.target, "data/secrets.json"), "corrupt"); + } + }, relocate: async () => { relocated = true; } }), /changed|verification/); + assert.equal(relocated, false); + assert.equal(await readFile(join(value.roots.data, "secrets.json"), "utf8"), "original"); +}); + +test("migration relocates internal absolute links and preserves external links", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); await put(join(value.roots.data, "sessions/current"), "session"); + await put(join(value.root, "outside"), "outside"); + await symlink(join(value.roots.data, "sessions/current"), join(value.roots.data, "absolute-link")); + await symlink(join(value.root, "outside"), join(value.roots.data, "external-link")); + const next = await migrate(value); + assert.equal(await readlink(join(next.data, "absolute-link")), join(next.data, "sessions/current")); + assert.equal(await readlink(join(next.data, "external-link")), join(value.root, "outside")); +}); + +test("relative links spanning data and browser roots retain their target after relocation", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + await put(join(value.roots.browser, "Local Storage/profile"), "linked browser data"); + await symlink(relative(value.roots.data, join(value.roots.browser, "Local Storage/profile")), join(value.roots.data, "browser-link")); + const next = await migrate(value); + assert.equal(await realpath(join(next.data, "browser-link")), join(next.browser, "Local Storage/profile")); +}); + +test("migration preserves external relative links and read-only files", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + const external = await put(join(value.root, "external-document"), "outside"); + await symlink(relative(value.roots.data, external), join(value.roots.data, "external-relative")); + await put(join(value.roots.data, "plugins/installed/read-only.js"), "plugin source", 0o444); + const next = await migrate(value); + assert.equal(await realpath(join(next.data, "external-relative")), external); + assert.equal((await lstat(join(next.data, "plugins/installed/read-only.js"))).mode & 0o777, 0o444); + assert.equal(await readFile(join(next.data, "plugins/installed/read-only.js"), "utf8"), "plugin source"); +}); + +test("migration re-reads and rejects a tampered read-only copy before relocating paths", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + await put(join(value.roots.data, "plugins/installed/read-only.js"), "plugin source", 0o444); + const fs = createRequire(import.meta.url)("node:fs"); + let corrupted = false; let relocated = false; + await assert.rejects(migrate(value, { progress: (event) => { + if (event.stage !== "verifying" || corrupted) return; + corrupted = true; + // Verification has to compare real bytes: a read-only copy that was tampered + // with after being written must still be detected, so the mode can never + // stand in for the content check. + const copy = join(value.target, "data/plugins/installed/read-only.js"); + fs.chmodSync(copy, 0o600); + fs.writeFileSync(copy, "tampered after the copy"); + fs.chmodSync(copy, 0o444); + }, relocate: async () => { relocated = true; } }), /changed|verification/); + assert.equal(relocated, false); + assert.equal(await readFile(join(value.roots.data, "plugins/installed/read-only.js"), "utf8"), "plugin source"); + assert.equal((await lstat(join(value.target, "data/plugins/installed/read-only.js"))).mode & 0o777, 0o444); +}); + +test("interrupted migration retries only the same claimed job and preserves the original", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); const id = randomUUID(); + await put(join(value.roots.data, "sessions/current"), "source session"); + await assert.rejects(migrate(value, { id, relocate: async () => { throw new Error("interrupted relocation"); } }), /interrupted/); + await assert.rejects(migrate(value), /unrelated data|empty/); + const next = await migrate(value, { id }); + assert.equal(await readFile(join(next.data, "sessions/current"), "utf8"), "source session"); + assert.equal(await readFile(join(value.roots.data, "sessions/current"), "utf8"), "source session"); +}); + +test("target validation rejects nonempty, overlapping and symbolic-link directories", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + await put(join(value.target, "unrelated")); + await assert.rejects(storage.validateTarget(value.target, value.roots, value.anchor), /empty/); + const nested = join(value.roots.data, "nested"); await mkdir(nested); + await assert.rejects(storage.validateTarget(nested, value.roots, value.anchor), /separate/); + await assert.rejects(storage.validateTarget(value.root, value.roots, value.anchor), /separate/); + const alias = join(value.root, "alias"); await symlink(value.target, alias); + await assert.rejects(storage.validateTarget(alias, value.roots, value.anchor), /symbolic link/); + assert.equal(await readFile(join(value.target, "unrelated"), "utf8"), "preserve this data"); +}); + +test("migration supports a clean installation whose data root does not yet exist", { timeout: 60_000 }, async (t) => { + const value = await fixture(t, { dataMissing: true }); + const next = await migrate(value); + assert.equal((await lstat(next.data)).isDirectory(), true); +}); + +test("migration copies a symbolic-link source root without keeping the old root active", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + await put(join(value.roots.data, "sessions/current"), "source conversation"); + const alias = join(value.root, "data-alias"); + await symlink(value.roots.data, alias); + value.roots = { ...value.roots, data: alias }; + const next = await migrate(value); + assert.equal((await lstat(next.data)).isDirectory(), true); + assert.equal((await lstat(next.data)).isSymbolicLink(), false); + assert.equal(await readFile(join(next.data, "sessions/current"), "utf8"), "source conversation"); +}); + +test("cache cleanup removes disposable caches while preserving sessions, secrets, plugin data and browser state", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + const caches = ["cache/thumbnails", "plugins/cache/download/pkg", "plugins/cache/backup/pkg", "openable-attachments/extracted"]; + const browserCaches = ["Cache/response", "Code Cache/script", "GPUCache/shader", "Partitions/plugin-1/Cache/response"]; + const protectedData = ["scratch/session/work.md", "sessions/current.jsonl", "secrets.json", "plugins/data/plugin-1/settings.json"]; + const protectedBrowser = ["Local Storage/leveldb/session.ldb", "Partitions/plugin-1/Local Storage/leveldb/config.ldb", "Cookies"]; + for (const path of caches) await put(join(value.roots.data, path), "1234"); + for (const path of browserCaches) await put(join(value.roots.browser, path), "1234"); + for (const path of protectedData) await put(join(value.roots.data, path), "durable"); + for (const path of protectedBrowser) await put(join(value.roots.browser, path), "durable"); + assert.equal(await storage.cacheSize(value.roots), (caches.length + browserCaches.length) * 4); + await storage.clearCaches(value.roots); + for (const path of caches) assert.equal(await exists(join(value.roots.data, path)), false, path); + for (const path of browserCaches) assert.equal(await exists(join(value.roots.browser, path)), false, path); + for (const path of protectedData) assert.equal(await readFile(join(value.roots.data, path), "utf8"), "durable", path); + for (const path of protectedBrowser) assert.equal(await readFile(join(value.roots.browser, path), "utf8"), "durable", path); + assert.equal(await storage.cacheSize(value.roots), 0); +}); + +test("cache cleanup does not follow plugin, partition or leaf cache links outside storage", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); const external = join(value.root, "external"); + await put(join(external, "cache/download/pkg"), "external"); + await put(join(external, "Cache/response"), "external"); + await symlink(external, join(value.roots.data, "plugins")); + await symlink(join(external, "Cache"), join(value.roots.data, "cache")); + await mkdir(join(value.roots.browser, "Partitions")); + await symlink(external, join(value.roots.browser, "Partitions/plugin-link")); + assert.equal(await storage.cacheSize(value.roots), 0); + await storage.clearCaches(value.roots); + assert.equal(await readFile(join(external, "cache/download/pkg"), "utf8"), "external"); + assert.equal(await readFile(join(external, "Cache/response"), "utf8"), "external"); +}); + +test("cache cleanup rejects intermediate links into durable data even inside the same storage root", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + await put(join(value.roots.data, "sessions/download/current.jsonl"), "durable conversation"); + await mkdir(join(value.roots.data, "plugins")); + await symlink(join(value.roots.data, "sessions"), join(value.roots.data, "plugins/cache")); + await storage.clearCaches(value.roots); + assert.equal(await readFile(join(value.roots.data, "sessions/download/current.jsonl"), "utf8"), "durable conversation"); + assert.equal(await storage.cacheSize(value.roots), 0); +}); + +test("cache cleanup refuses a leaf cache link that redirects to durable data in the same profile", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + await put(join(value.roots.data, "sessions/download/current.jsonl"), "durable conversation"); + await symlink(join(value.roots.data, "sessions"), join(value.roots.data, "cache")); + assert.equal(await storage.cacheSize(value.roots), 0); + await storage.clearCaches(value.roots); + assert.equal(await readFile(join(value.roots.data, "sessions/download/current.jsonl"), "utf8"), "durable conversation"); + assert.equal((await lstat(join(value.roots.data, "cache"))).isSymbolicLink(), true); +}); + +test("backup removal protects active storage and the installation bootstrap anchor", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); const active = { data: join(value.target, "data"), browser: join(value.target, "browser") }; + await Promise.all([mkdir(active.data), mkdir(active.browser)]); + await put(join(active.data, "session"), "active"); + await put(join(value.roots.data, "old-session"), "old"); + await put(join(value.anchor, storage.STORAGE_PREFERENCE_FILE), "pointer"); + await put(join(value.anchor, "SingletonLock"), "lock"); + await put(join(value.anchor, "Local Storage/old"), "old browser"); + await assert.rejects(storage.removeBackups([active], active, value.anchor), /active storage/); + await storage.removeBackups([value.roots], active, value.anchor); + assert.equal(await exists(value.roots.data), false); + assert.equal(await readFile(join(value.anchor, storage.STORAGE_PREFERENCE_FILE), "utf8"), "pointer"); + assert.equal(await readFile(join(value.anchor, "SingletonLock"), "utf8"), "lock"); + assert.equal(await exists(join(value.anchor, "Local Storage/old")), false); + assert.equal(await readFile(join(active.data, "session"), "utf8"), "active"); +}); + +test("backup deletion canonicalizes active roots before checking overlaps", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + const alias = join(value.root, "active-data-alias"); + await put(join(value.roots.data, "sessions/current"), "active conversation"); + await symlink(value.roots.data, alias); + const active = { data: alias, browser: value.target }; + await assert.rejects(storage.removeBackups([{ data: value.roots.data, browser: value.roots.browser }], active, value.anchor), /active storage/); + assert.equal(await readFile(join(alias, "sessions/current"), "utf8"), "active conversation"); +}); + +test("backup cleanup validates all roots before deleting any backup", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); + await put(join(value.roots.data, "sessions/backup"), "preserve backup"); + await put(join(value.target, "active/current"), "active"); + const active = { data: join(value.target, "active"), browser: value.roots.browser }; + await assert.rejects(storage.removeBackups([{ data: value.roots.data, browser: active.browser }], active, value.anchor), /active storage/); + assert.equal(await readFile(join(value.roots.data, "sessions/backup"), "utf8"), "preserve backup"); +}); + +test("storage preferences round-trip pending work atomically and reject malformed pointers", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); const file = join(value.anchor, storage.STORAGE_PREFERENCE_FILE); + assert.deepEqual(storage.readStoragePreferences(file, value.roots), { version: 1, roots: value.roots, backups: [] }); + const preferences = { version: 1, roots: value.roots, backups: [], pending: { id: randomUUID(), kind: "migrate", target: value.target, language: "en" } }; + storage.writeStoragePreferences(file, preferences); + assert.deepEqual(storage.readStoragePreferences(file, value.roots), preferences); + assert.equal((await lstat(file)).mode & 0o777, 0o600); + await writeFile(file, JSON.stringify({ ...preferences, roots: { data: "relative", browser: value.anchor } })); + assert.throws(() => storage.readStoragePreferences(file, value.roots), /Invalid storage preferences/); +}); + +test("setting the path schedules a cold restart, migration switches the pointer only after verification", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); resetHarness(value.anchor); + await put(join(value.roots.data, "sessions/current"), "conversation"); + await storage.prepareStorage(value.roots.data, false); + const invoke = registerHandlers(); harness.selection = value.target; + assert.equal(await invoke("storageChoose"), value.target); + await invoke("storageMigrate", { path: value.target, language: "en" }); + const file = join(value.anchor, storage.STORAGE_PREFERENCE_FILE); + const pending = storage.readStoragePreferences(file, value.roots); + assert.deepEqual(pending.roots, value.roots); + assert.equal(pending.pending.target, value.target); + assert.equal(harness.restarts, 1); + await assert.rejects(storage.prepareStorage(value.roots.data, false), (error) => error === exitSignal); + const complete = storage.readStoragePreferences(file, value.roots); + assert.deepEqual(complete.roots, { data: join(value.target, "data"), browser: join(value.target, "browser") }); + assert.deepEqual(complete.backups, [value.roots]); + assert.equal(complete.pending, undefined); + assert.equal(await readFile(join(complete.roots.data, "sessions/current"), "utf8"), "conversation"); + assert.equal(harness.windows[0].options.webPreferences.partition.startsWith("persist:"), false); + assert.equal(harness.windows[0].options.webPreferences.nodeIntegration, false); + assert.ok(harness.windows[0].scripts.some((script) => script.includes(storage.catalogs.en.settings.storage.stages.relocating))); +}); + +test("bootstrap refuses missing custom storage without silently creating an empty profile", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); resetHarness(value.anchor); + const unavailable = { data: join(value.target, "disconnected-data"), browser: join(value.target, "disconnected-browser") }; + const file = join(value.anchor, storage.STORAGE_PREFERENCE_FILE); + storage.writeStoragePreferences(file, { version: 1, roots: unavailable, backups: [] }); + const before = await readFile(file); + await assert.rejects(storage.prepareStorage(value.roots.data, false), (error) => error === exitSignal); + assert.equal(harness.errors.length, 1); + assert.match(harness.errors[0][0].detail, /unavailable/); + assert.equal(harness.paths.length, 0); + assert.equal(await exists(unavailable.data), false); + assert.equal(await exists(unavailable.browser), false); + assert.deepEqual(await readFile(file), before); +}); + +test("failed migration keeps the original pointer and lets the user retry the same destination", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); resetHarness(value.anchor); + await put(join(value.roots.data, "sessions/current"), "conversation"); + await storage.prepareStorage(value.roots.data, false); + harness.selection = value.target; + let invoke = registerHandlers(); await invoke("storageChoose"); + await invoke("storageMigrate", { path: value.target, language: "en" }); + harness.hostError = new Error("interrupted host relocation"); + await assert.rejects(storage.prepareStorage(value.roots.data, false), (error) => error === exitSignal); + const file = join(value.anchor, storage.STORAGE_PREFERENCE_FILE); + const failed = storage.readStoragePreferences(file, value.roots); + assert.deepEqual(failed.roots, value.roots); + assert.match(failed.lastError, /interrupted host relocation/); + assert.equal(harness.errors.length, 1); + harness.hostError = null; + await storage.prepareStorage(value.roots.data, false); + invoke = registerHandlers(); await invoke("storageChoose"); + await invoke("storageMigrate", { path: value.target, language: "en" }); + await assert.rejects(storage.prepareStorage(value.roots.data, false), (error) => error === exitSignal); + assert.equal(storage.readStoragePreferences(file, value.roots).roots.data, join(value.target, "data")); +}); + +test("IPC refuses untrusted senders, arbitrary paths, duplicate jobs and environment overrides", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); resetHarness(value.anchor); + await storage.prepareStorage(value.roots.data, false); + let invoke = registerHandlers(); + await assert.rejects(invoke("storageGet", undefined, "plugin"), /untrusted/); + await assert.rejects(invoke("storageMigrate", { path: value.target, language: "en" }), /picker/); + await invoke("storageClearCache", { language: "en" }); + await assert.rejects(invoke("storageClearCache", { language: "en" }), /pending/); + await storage.prepareStorage(value.roots.data, true); + invoke = registerHandlers(); + await assert.rejects(invoke("storageChoose"), /PI_DESKTOP_DATA_DIR/); + await assert.rejects(invoke("storageClearCache", { language: "en" }), /PI_DESKTOP_DATA_DIR/); +}); + +test("settings cache and backup actions run cold maintenance without changing the active roots", { timeout: 60_000 }, async (t) => { + const value = await fixture(t); resetHarness(value.anchor); + const active = { data: join(value.target, "data"), browser: join(value.target, "browser") }; + await put(join(active.data, "cache/temporary"), "temporary"); + await put(join(active.data, "scratch/session/current.md"), "active conversation"); + await put(join(active.browser, "Local Storage/current"), "active browser state"); + await put(join(value.roots.data, "sessions/old"), "backup conversation"); + const file = join(value.anchor, storage.STORAGE_PREFERENCE_FILE); + storage.writeStoragePreferences(file, { version: 1, roots: active, backups: [value.roots] }); + await storage.prepareStorage(value.roots.data, false); + let invoke = registerHandlers(); + await invoke("storageClearCache", { language: "en" }); + assert.equal(await exists(join(active.data, "cache/temporary")), true); + await assert.rejects(storage.prepareStorage(value.roots.data, false), (error) => error === exitSignal); + assert.equal(await exists(join(active.data, "cache/temporary")), false); + let preferences = storage.readStoragePreferences(file, value.roots); + assert.deepEqual(preferences.roots, active); + assert.deepEqual(preferences.backups, [value.roots]); + assert.equal(preferences.pending, undefined); + await storage.prepareStorage(value.roots.data, false); + invoke = registerHandlers(); + await invoke("storageRemoveBackup", { language: "en" }); + await assert.rejects(storage.prepareStorage(value.roots.data, false), (error) => error === exitSignal); + preferences = storage.readStoragePreferences(file, value.roots); + assert.deepEqual(preferences.roots, active); + assert.deepEqual(preferences.backups, []); + assert.equal(await exists(value.roots.data), false); + assert.equal(await readFile(join(active.data, "scratch/session/current.md"), "utf8"), "active conversation"); + assert.equal(await readFile(join(active.browser, "Local Storage/current"), "utf8"), "active browser state"); + assert.equal(await exists(file), true); +}); diff --git a/crates/host-core/src/data_relocation.rs b/crates/host-core/src/data_relocation.rs new file mode 100644 index 0000000000..14e7ab4082 --- /dev/null +++ b/crates/host-core/src/data_relocation.rs @@ -0,0 +1,415 @@ +//! Offline relocation of host-owned structured paths in a verified profile copy. +//! The source remains untouched. No schema migration, recovery, or sweep runs. + +use anyhow::{bail, Context, Result}; +use rusqlite::{params, Connection, OpenFlags}; +use serde_json::Value; +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Component, Path, PathBuf}; + +#[cfg(test)] +mod tests; + +pub fn run_cli() -> Result { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if args.first().is_none_or(|arg| arg != "--relocate-data") { + return Ok(false); + } + if args.len() != 3 { + bail!("usage: pi-desktop-host-core --relocate-data "); + } + relocate(Path::new(&args[1]), Path::new(&args[2]))?; + Ok(true) +} + +struct Roots { + source: PathBuf, + canonical_source: PathBuf, + destination: PathBuf, +} + +impl Roots { + fn remap(&self, text: &str) -> Option { + // A component boundary prevents matching `.pi-desktop-other`; reject + // traversal spellings rather than converting them to privileged paths. + let path = Path::new(text); + if !path.is_absolute() || path.components().any(|part| part == Component::ParentDir) { + return None; + } + let suffix = path + .strip_prefix(&self.source) + .or_else(|_| path.strip_prefix(&self.canonical_source)) + .ok()?; + Some(self.destination.join(suffix).to_string_lossy().into_owned()) + } + + fn string(&self, value: &mut Value) -> bool { + let Some(next) = value.as_str().and_then(|text| self.remap(text)) else { + return false; + }; + *value = Value::String(next); + true + } + + fn structured(&self, value: &mut Value) -> bool { + match value { + Value::Array(items) => items + .iter_mut() + .fold(false, |changed, item| self.structured(item) | changed), + Value::Object(object) => { + let mut changed = false; + for (key, item) in object { + match key.as_str() { + "path" | "ref" | "filePath" | "scratchReportPath" | "workspacePath" + | "projectPath" | "primaryPath" | "cwd" | "scratchDir" + | "attachmentsDir" | "sourcePath" => changed |= self.string(item), + "detachedPaths" | "openProjectPaths" | "paths" => { + if let Value::Array(paths) = item { + for path in paths { + changed |= self.string(path); + } + } + } + // Never rewrite narrative content, shell commands, source + // code, credentials, or arbitrary serialized user input. + "text" | "content" | "summary" | "command" | "code" | "prompt" + | "markdown" | "instructions" | "env" => {} + _ => changed |= self.structured(item), + } + } + changed + } + _ => false, + } + } +} + +fn regular_file(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => Ok(true), + Ok(_) => bail!("relocation metadata is not a regular file"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +fn regular_directory(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => Ok(true), + Ok(_) => bail!("relocation metadata directory is not a regular directory"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +fn replace_file(path: &Path, write: impl FnOnce(&mut fs::File) -> Result) -> Result<()> { + let temporary = path.with_extension(format!("relocate-{}", uuid::Uuid::new_v4())); + let result = (|| { + let permissions = fs::metadata(path)?.permissions(); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + options.mode(permissions.mode()); + } + let mut output = options.open(&temporary)?; + output.set_permissions(permissions)?; + if write(&mut output)? { + output.sync_all()?; + drop(output); + fs::rename(&temporary, path)?; + } else { + drop(output); + fs::remove_file(&temporary)?; + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result +} + +fn rewrite_json(path: &Path, rewrite: impl FnOnce(&mut Value) -> bool) -> Result<()> { + if !regular_file(path)? { + return Ok(()); + } + let mut value: Value = + serde_json::from_slice(&fs::read(path)?).context("parse relocation metadata")?; + replace_file(path, |output| { + let changed = rewrite(&mut value); + if changed { + serde_json::to_writer(output, &value)?; + } + Ok(changed) + }) +} + +fn rewrite_transcript(path: &Path, roots: &Roots) -> Result<()> { + if !regular_file(path)? { + return Ok(()); + } + replace_file(path, |output| { + let mut changed = false; + let mut reader = BufReader::new(fs::File::open(path)?); + let mut line = Vec::new(); + loop { + line.clear(); + if reader.read_until(b'\n', &mut line)? == 0 { + break; + } + let mut value: Value = match serde_json::from_slice(&line) { + Ok(value) => value, + // Transcript readers already tolerate a crash-torn final line. + Err(_) if !line.ends_with(b"\n") => { + output.write_all(&line)?; + break; + } + Err(error) => return Err(error).context("parse relocation transcript"), + }; + if roots.structured(&mut value) { + serde_json::to_writer(&mut *output, &value)?; + if line.ends_with(b"\n") { + output.write_all(b"\n")?; + } + changed = true; + } else { + output.write_all(&line)?; + } + } + Ok(changed) + }) +} + +// Field order is significant: CapabilityState uses the serialized object as +// its lookup key rather than comparing parsed identities. +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct CapabilityIdentity { + kind: String, + level: String, + id: String, + #[serde(default)] + project_path: Option, +} + +fn rewrite_capability_state(path: &Path, roots: &Roots) -> Result<()> { + if !regular_file(path)? { + return Ok(()); + } + let mut value: Value = + serde_json::from_slice(&fs::read(path)?).context("parse relocation capability metadata")?; + let Some(values) = value.get_mut("values").and_then(Value::as_object_mut) else { + bail!("invalid relocation capability metadata"); + }; + let mut next = serde_json::Map::new(); + let mut changed = false; + for (key, enabled) in values.iter() { + // CapabilityState serializes its project identity inside a JSON key. + // Global identities and unknown historical keys remain unchanged. + let next_key = if let Ok(mut identity) = serde_json::from_str::(key) { + if let Some(project) = identity + .project_path + .as_deref() + .and_then(|path| roots.remap(path)) + { + identity.project_path = Some(project); + changed = true; + serde_json::to_string(&identity)? + } else { + key.clone() + } + } else { + key.clone() + }; + if next.insert(next_key, enabled.clone()).is_some() { + bail!("relocated capability identity conflicts with an existing identity"); + } + } + if changed { + *values = next; + replace_file(path, |output| { + serde_json::to_writer(output, &value)?; + Ok(true) + })?; + } + Ok(()) +} + +/// Called only after every writer has exited and the complete copy was verified. +/// Failure leaves an unpublished destination; the bootstrap keeps the old root. +pub fn relocate(source: &Path, destination: &Path) -> Result<()> { + if !source.is_absolute() || !destination.is_absolute() { + bail!("relocation roots must be absolute"); + } + if !regular_directory(source)? || !regular_directory(destination)? { + bail!("relocation roots must exist"); + } + let canonical_source = source.canonicalize()?; + let canonical_destination = destination.canonicalize()?; + if canonical_source.starts_with(&canonical_destination) + || canonical_destination.starts_with(&canonical_source) + { + bail!("relocation roots must be disjoint"); + } + let roots = Roots { + source: source.to_path_buf(), + canonical_source, + destination: destination.to_path_buf(), + }; + let sessions = roots.destination.join("sessions"); + if regular_directory(&sessions)? { + for entry in fs::read_dir(sessions)? { + let path = entry?.path(); + match path.extension().and_then(|extension| extension.to_str()) { + Some("jsonl") => rewrite_transcript(&path, &roots)?, + Some("json") + if path + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with(".inflight.json")) => + { + rewrite_json(&path, |value| roots.structured(value))?; + } + _ => {} + } + } + } + rewrite_json( + &roots.destination.join("session-message-outbox.json"), + |value| roots.structured(value), + )?; + let capabilities = roots.destination.join("agent-capabilities"); + if regular_directory(&capabilities)? { + for name in ["mcp", "skills", "subagents", "subagent-builtins"] { + rewrite_capability_state(&capabilities.join(format!("{name}.json")), &roots)?; + } + } + let plugins = roots.destination.join("plugins"); + if regular_directory(&plugins)? { + rewrite_json(&plugins.join("registry.json"), |value| { + let mut changed = false; + if let Some(entries) = value.as_array_mut() { + for entry in entries { + if matches!( + entry.get("source").and_then(Value::as_str), + Some("installed" | "marketplace") + ) { + if let Some(path) = entry.get_mut("path") { + changed |= roots.string(path); + } + } + } + } + changed + })?; + } + relocate_database(&roots)?; + Ok(()) +} + +fn column_exists(connection: &Connection, table: &str, column: &str) -> Result { + let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?; + let names = statement.query_map([], |row| row.get::<_, String>(1))?; + for name in names { + if name? == column { + return Ok(true); + } + } + Ok(false) +} + +fn relocate_database(roots: &Roots) -> Result<()> { + let path = roots.destination.join("pi.sqlite"); + if !regular_file(&path)? { + return Ok(()); + } + for extension in ["pi.sqlite-wal", "pi.sqlite-shm"] { + regular_file(&roots.destination.join(extension))?; + } + let mut connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + connection.busy_timeout(std::time::Duration::from_secs(5))?; + let transaction = connection.transaction()?; + for (table, column, json) in [ + ("projects", "path", false), + ("artifacts", "path", false), + ("turn_queue", "attachments_json", true), + ("scheduled_tasks", "config_json", true), + ] { + if !column_exists(&transaction, table, column)? { + continue; + } + let rows = { + let mut statement = transaction.prepare(&format!( + "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL" + ))?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + rows + }; + for text in rows { + let next = if json { + let mut value: Value = + serde_json::from_str(&text).context("parse relocation database metadata")?; + roots.structured(&mut value).then(|| value.to_string()) + } else { + roots.remap(&text) + }; + if let Some(next) = next { + transaction.execute( + &format!("UPDATE {table} SET {column} = ?1 WHERE {column} = ?2"), + params![next, text], + )?; + } + } + } + if column_exists(&transaction, "kv", "value_json")? { + let rows = { + let mut statement = transaction.prepare("SELECT ns, key, value_json FROM kv WHERE ns IN ('app','ui','projectGroups','projectMemory','projectInstructions')")?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::>>()?; + rows + }; + for (namespace, key, text) in rows { + let next_key = if matches!(namespace.as_str(), "projectMemory" | "projectInstructions") + { + roots.remap(&key).unwrap_or_else(|| key.clone()) + } else { + key.clone() + }; + let mut value: Value = + serde_json::from_str(&text).context("parse relocation settings metadata")?; + let changed = if namespace == "app" && key == "currentProjectId" { + roots.string(&mut value) + } else if matches!(namespace.as_str(), "app" | "ui" | "projectGroups") { + roots.structured(&mut value) + } else { + false + }; + if changed || next_key != key { + transaction.execute( + "UPDATE kv SET key = ?1, value_json = ?2 WHERE ns = ?3 AND key = ?4", + params![next_key, value.to_string(), namespace, key], + )?; + } + } + } + transaction.commit()?; + let (busy, _, _): (i64, i64, i64) = + connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + if busy != 0 { + bail!("relocated database checkpoint is busy"); + } + Ok(()) +} diff --git a/crates/host-core/src/data_relocation/tests.rs b/crates/host-core/src/data_relocation/tests.rs new file mode 100644 index 0000000000..e3ff0d3a05 --- /dev/null +++ b/crates/host-core/src/data_relocation/tests.rs @@ -0,0 +1,391 @@ +use super::*; +use crate::{db::Database, secrets::SecretStore, sessions}; +use serde_json::json; + +fn copy_directory(source: &Path, destination: &Path) { + fs::create_dir_all(destination).unwrap(); + for entry in fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + let target = destination.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_directory(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).unwrap(); + } + } +} + +fn read_json(path: &Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +#[test] +fn relocates_a_real_copied_profile_without_recovery_or_changing_user_content() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("old"); + let destination = temporary.path().join("new"); + let attachment = source.join("scratch/chat/pasted/input.txt"); + fs::create_dir_all(attachment.parent().unwrap()).unwrap(); + fs::write(&attachment, "user file bytes").unwrap(); + let project = source.join("project"); + fs::create_dir_all(&project).unwrap(); + let external = temporary.path().join("external-project"); + fs::create_dir_all(&external).unwrap(); + let database = Database::open_in_dir(&source).unwrap(); + let session = + sessions::create_session(&database, Some("Saved chat".into()), None, None, None, None) + .unwrap(); + database.conn().execute("INSERT INTO projects(path,name,created_at,last_opened_at) VALUES(?1,'inside',0,0), (?2,'outside',0,0)", params![project.to_string_lossy(), external.to_string_lossy()]).unwrap(); + database + .conn() + .execute( + "INSERT INTO turns(id,session_id,status,started_at) VALUES ('live',?1,'running',0)", + params![session.id], + ) + .unwrap(); + database + .conn() + .execute( + "INSERT INTO artifacts(session_id,path,op,updated_at) VALUES (?1,?2,'write',0)", + params![session.id, attachment.to_string_lossy()], + ) + .unwrap(); + database.conn().execute("INSERT INTO turn_queue(id,session_id,principal,input_hash,content,attachments_json,permission_mode,position,created_at) VALUES ('queued',?1,'user','hash',?2,?3,'ask',0,0)", params![session.id, attachment.to_string_lossy(), json!([{"ref":attachment}]).to_string()]).unwrap(); + database.conn().execute("INSERT INTO kv(ns,key,value_json,updated_at) VALUES ('projectMemory',?1,?2,0),('projectGroups','group',?3,0)", params![project.to_string_lossy(), json!({"content":attachment.to_string_lossy()}).to_string(), json!({"primaryPath":project,"roots":[{"path":project}],"detachedPaths":[source.join("removed")]}).to_string()]).unwrap(); + drop(database); + let secrets = SecretStore::open(&source).unwrap(); + secrets.set("secret:test", "fixture-credential").unwrap(); + drop(secrets); + let line = json!({"type":"message","id":"m1","blocks":[ + {"type":"text","text":attachment}, + {"type":"attachment","ref":attachment}, + {"type":"tool_call","args":{"path":attachment,"command":attachment,"content":attachment},"result":{"scratchReportPath":attachment,"text":attachment}} + ]}).to_string(); + let compaction = json!({"type":"compaction","summary":attachment,"retainedTail":[{"blocks":[{"type":"attachment","ref":attachment}]}]}).to_string(); + let transcript = source.join("sessions/chat.jsonl"); + fs::create_dir_all(source.join("sessions")).unwrap(); + fs::create_dir_all(source.join("plugins")).unwrap(); + fs::write(&transcript, format!("{line}\n{compaction}\n{{\"type\":")).unwrap(); + fs::write( + source.join("sessions/chat.revisions.jsonl"), + format!( + "{}\n", + json!({"type":"revision","messages":[serde_json::from_str::(&line).unwrap()]}) + ), + ) + .unwrap(); + fs::write( + source.join("sessions/chat.inflight.json"), + json!({"message":{"blocks":[{"ref":attachment}]}}).to_string(), + ) + .unwrap(); + fs::write( + source.join("session-message-outbox.json"), + json!([{"message":{"attachments":[{"ref":attachment}],"content":attachment}}]).to_string(), + ) + .unwrap(); + fs::write( + source.join("plugins/registry.json"), + json!([ + {"source":"installed","path":source.join("plugins/installed/example")}, + {"source":"dev","path":source.join("dev-source")}, + {"source":"builtin","path":external}, + {"source":"marketplace","path":format!("{}-other/plugins", source.display())} + ]) + .to_string(), + ) + .unwrap(); + copy_directory(&source, &destination); + let original = fs::read(&transcript).unwrap(); + let key_before = fs::read(source.join("secrets/.machine-key")).unwrap(); + relocate(&source, &destination).unwrap(); + let moved_attachment = destination.join("scratch/chat/pasted/input.txt"); + let new_transcript = fs::read_to_string(destination.join("sessions/chat.jsonl")).unwrap(); + let lines: Vec<_> = new_transcript.lines().collect(); + let message: Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(message["blocks"][1]["ref"], json!(moved_attachment)); + assert_eq!(message["blocks"][0]["text"], json!(attachment)); + assert_eq!( + message["blocks"][2]["args"]["path"], + json!(moved_attachment) + ); + assert_eq!(message["blocks"][2]["args"]["command"], json!(attachment)); + assert_eq!(message["blocks"][2]["args"]["content"], json!(attachment)); + assert_eq!( + message["blocks"][2]["result"]["scratchReportPath"], + json!(moved_attachment) + ); + let compacted: Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(compacted["summary"], json!(attachment)); + assert_eq!( + compacted["retainedTail"][0]["blocks"][0]["ref"], + json!(moved_attachment) + ); + assert_eq!(lines[2], "{\"type\":"); + let revision: Value = serde_json::from_str( + fs::read_to_string(destination.join("sessions/chat.revisions.jsonl")) + .unwrap() + .trim(), + ) + .unwrap(); + assert_eq!( + revision["messages"][0]["blocks"][1]["ref"], + json!(moved_attachment) + ); + assert_eq!( + read_json(&destination.join("sessions/chat.inflight.json"))["message"]["blocks"][0]["ref"], + json!(moved_attachment) + ); + assert_eq!( + read_json(&destination.join("session-message-outbox.json"))[0]["message"]["attachments"][0] + ["ref"], + json!(moved_attachment) + ); + let plugins = read_json(&destination.join("plugins/registry.json")); + assert_eq!( + plugins[0]["path"], + json!(destination.join("plugins/installed/example")) + ); + assert_eq!(plugins[1]["path"], json!(source.join("dev-source"))); + assert_eq!(plugins[2]["path"], json!(external)); + assert_eq!( + plugins[3]["path"], + json!(format!("{}-other/plugins", source.display())) + ); + let connection = Connection::open(destination.join("pi.sqlite")).unwrap(); + let inside: String = connection + .query_row("SELECT path FROM projects WHERE name='inside'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(inside, destination.join("project").to_string_lossy()); + let outside: String = connection + .query_row( + "SELECT path FROM projects WHERE name='outside'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(outside, external.to_string_lossy()); + let status: String = connection + .query_row("SELECT status FROM turns WHERE id='live'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!( + status, "running", + "offline relocation must not execute startup recovery" + ); + let queued: String = connection + .query_row("SELECT attachments_json FROM turn_queue", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!( + serde_json::from_str::(&queued).unwrap()[0]["ref"], + json!(moved_attachment) + ); + let memory_key: String = connection + .query_row("SELECT key FROM kv WHERE ns='projectMemory'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(memory_key, destination.join("project").to_string_lossy()); + let group: String = connection + .query_row( + "SELECT value_json FROM kv WHERE ns='projectGroups'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&group).unwrap()["roots"][0]["path"], + json!(destination.join("project")) + ); + assert_eq!( + SecretStore::open(&destination) + .unwrap() + .get("secret:test") + .unwrap() + .as_deref(), + Some("fixture-credential") + ); + assert_eq!( + fs::read(destination.join("secrets/.machine-key")).unwrap(), + key_before + ); + assert_eq!(fs::read(&transcript).unwrap(), original); + assert_eq!(fs::read(moved_attachment).unwrap(), b"user file bytes"); + drop(connection); + // A retry is harmless even when the previous relocation finished fully. + relocate(&source, &destination).unwrap(); +} + +#[test] +fn exact_path_boundaries_preserve_prose_prefix_neighbors_and_traversal() { + let temporary = tempfile::tempdir().unwrap(); + let roots = Roots { + source: temporary.path().join("old"), + canonical_source: temporary.path().join("old"), + destination: temporary.path().join("new"), + }; + let inside = roots.source.join("nested/file"); + let neighbor = format!("{}-other/file", roots.source.display()); + let traversal = roots.source.join("../external/file"); + let prose = format!("cat {}", inside.display()); + let mut value = json!({"path":inside, "ref":neighbor, "args":{"command":prose, "path":traversal}, "text":inside, "userCode":{"content":{"path":inside}}}); + assert!(roots.structured(&mut value)); + assert_eq!(value["path"], json!(roots.destination.join("nested/file"))); + assert_eq!(value["ref"], json!(neighbor)); + assert_eq!(value["args"]["path"], json!(traversal)); + assert_eq!(value["userCode"]["content"]["path"], json!(inside)); + assert_eq!(value["text"], json!(inside)); +} + +#[test] +fn refuses_overlapping_roots_and_malformed_complete_records() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("old"); + let destination = temporary.path().join("new"); + fs::create_dir_all(source.join("nested")).unwrap(); + assert!(relocate(&source, &source.join("nested")).is_err()); + fs::create_dir_all(destination.join("sessions")).unwrap(); + fs::write(destination.join("sessions/chat.jsonl"), b"not json\n").unwrap(); + assert!(relocate(&source, &destination).is_err()); + assert_eq!( + fs::read(destination.join("sessions/chat.jsonl")).unwrap(), + b"not json\n" + ); +} + +#[cfg(unix)] +#[test] +fn refuses_symlinked_host_metadata_without_touching_external_files() { + use std::os::unix::fs::symlink; + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("old"); + let destination = temporary.path().join("new"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&destination).unwrap(); + let external = temporary.path().join("external"); + fs::write(&external, "private fixture").unwrap(); + symlink(&external, destination.join("pi.sqlite")).unwrap(); + assert!(relocate(&source, &destination).is_err()); + assert_eq!(fs::read(external).unwrap(), b"private fixture"); +} + +#[test] +fn handles_legacy_schema_without_upgrading_or_creating_missing_tables() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("old"); + let destination = temporary.path().join("new"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&destination).unwrap(); + let database = Connection::open(destination.join("pi.sqlite")).unwrap(); + database.execute_batch("PRAGMA user_version=6; CREATE TABLE projects(id INTEGER PRIMARY KEY,path TEXT UNIQUE);").unwrap(); + database + .execute( + "INSERT INTO projects(path) VALUES (?1)", + params![source.join("project").to_string_lossy()], + ) + .unwrap(); + drop(database); + relocate(&source, &destination).unwrap(); + let database = Connection::open(destination.join("pi.sqlite")).unwrap(); + let version: i64 = database + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 6); + let path: String = database + .query_row("SELECT path FROM projects", [], |row| row.get(0)) + .unwrap(); + assert_eq!(path, destination.join("project").to_string_lossy()); + let tables: i64 = database + .query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tables, 1); + assert!(!destination.join("sessions").exists()); + assert!(!destination.join("secrets").exists()); +} + +#[test] +fn project_capability_overrides_follow_the_moved_project_without_changing_globals() { + use crate::agent_capabilities::{CapabilityLevel, CapabilityState}; + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("old"); + let destination = temporary.path().join("new"); + fs::create_dir_all(&source).unwrap(); + let project = source.join("project").to_string_lossy().into_owned(); + let mut original = CapabilityState::new(&source, "skills"); + original + .set_enabled( + "skills", + CapabilityLevel::Project, + "analysis", + Some(&project), + false, + ) + .unwrap(); + original + .set_enabled( + "skills", + CapabilityLevel::Global, + "global-analysis", + None, + false, + ) + .unwrap(); + copy_directory(&source, &destination); + relocate(&source, &destination).unwrap(); + let moved_project = destination.join("project").to_string_lossy().into_owned(); + let restored = CapabilityState::new(&destination, "skills"); + assert!(!restored.enabled( + "skills", + CapabilityLevel::Project, + "analysis", + Some(&moved_project) + )); + assert!(!restored.enabled("skills", CapabilityLevel::Global, "global-analysis", None)); + assert!(restored.enabled( + "skills", + CapabilityLevel::Project, + "analysis", + Some(&project) + )); + assert!(!original.enabled( + "skills", + CapabilityLevel::Project, + "analysis", + Some(&project) + )); +} + +#[test] +fn refuses_colliding_project_capability_keys_without_losing_an_override() { + use crate::agent_capabilities::{CapabilityLevel, CapabilityState}; + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("old"); + let destination = temporary.path().join("new"); + fs::create_dir_all(&source).unwrap(); + let mut original = CapabilityState::new(&source, "skills"); + for project in [source.join("project"), destination.join("project")] { + original + .set_enabled( + "skills", + CapabilityLevel::Project, + "analysis", + Some(&project.to_string_lossy()), + false, + ) + .unwrap(); + } + copy_directory(&source, &destination); + let file = destination.join("agent-capabilities/skills.json"); + let before = fs::read(&file).unwrap(); + assert!(relocate(&source, &destination).is_err()); + assert_eq!(fs::read(file).unwrap(), before); +} diff --git a/crates/host-core/src/main.rs b/crates/host-core/src/main.rs index 2330c3da05..bcc75a0ba6 100644 --- a/crates/host-core/src/main.rs +++ b/crates/host-core/src/main.rs @@ -3,6 +3,7 @@ mod agent_capabilities; mod artifacts; mod audit; mod config_sync; +mod data_relocation; mod db; mod keyboard; mod mcp_servers; @@ -44,6 +45,9 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; #[tokio::main] async fn main() -> anyhow::Result<()> { + if data_relocation::run_cli()? { + return Ok(()); + } if std::env::args().any(|arg| arg == tools::INTERNAL_TOOL_RUNNER_FLAG) { let exit_code = match tools::run_internal_tool_runner().await { Ok(exit_code) => exit_code, diff --git a/docs/adr/0094-single-instance-per-data-directory.md b/docs/adr/0094-single-instance-per-data-directory.md index ea419c9f73..b4d72304a0 100644 --- a/docs/adr/0094-single-instance-per-data-directory.md +++ b/docs/adr/0094-single-instance-per-data-directory.md @@ -96,3 +96,12 @@ and the two never share `pi.sqlite`, the outbox, or the log tree. An explicit throwaway profile with it. Only the development side moved: a shipped installation keeps `PI-Desktop` and `~/.pi-desktop`, so no existing profile is relocated. See D599. + +## Amendment: explicit storage relocation (2026-10-01) + +Issue #1213 adds an explicitly confirmed, offline relocation of the complete app +and Chromium profile. The stable `userData` installation lock is retained, while +Chromium `sessionData` may follow the copied browser profile. No silent relocation +or reset of existing renderer state is introduced. The original rejection of +implicitly relocating `userData` for locking remains valid. See +[Custom storage location](custom-storage-location.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index f53e5db1ae..c0f5d010f2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -120,6 +120,7 @@ Each ADR includes: | 0092 | Use a plugin-owned surface with a host window-control capsule | Accepted | | 0093 | Keep a strict 46px plugin drag band with a minimal capsule | Accepted | | 0094 | Admit one desktop instance per data directory | Accepted | +| custom-storage-location | [Custom storage location with cold migration](custom-storage-location.md) | Accepted | | 0095 | Sign in with a vendor account instead of pasting an API key | Accepted for implementation | | 0096 | Flatten the Settings directory and colocate marketplace source configuration | Accepted | | 0097 | Place global defaults under the AI settings destination | Accepted | diff --git a/docs/adr/custom-storage-location.md b/docs/adr/custom-storage-location.md new file mode 100644 index 0000000000..780f950c8c --- /dev/null +++ b/docs/adr/custom-storage-location.md @@ -0,0 +1,61 @@ +# ADR: Custom storage location with cold migration + +- Status: Accepted +- Date: 2026-10-01 +- Related: issue #1213, ADR 0011, ADR 0094 + +## Context + +Application data and Chromium's browser/plugin state accumulate on the default +system volume. Changing only the host root strands browser state and absolute +internal attachment/plugin references. Copying a live database/profile also risks +inconsistent snapshots and writes arriving after the location has changed. + +## Decision + +Use explicit settings confirmation followed by existing ordered shutdown. Journal +pending work in the original Electron userData directory, which remains the stable +installation identity and single-instance lock. Before booting application writers, +a sandboxed nonpersistent Electron window displays cold migration progress. + +An empty selected destination owns `data/` and `browser/`. The production Node +filesystem service copies all profile files, preserves links/permissions, verifies +SHA-256 bytes and records ownership for retry. Rust alone rewrites known host-owned +structured paths and the copied SQLite index in an offline CLI mode, without +schema upgrades, startup recovery, or scratch sweeps. The durable pointer is +atomically published after these steps. Normal startup sets Chromium sessionData +before ready and passes the selected data root to all existing services. + +Keep source profiles as visible backups, with a separate confirmed cleanup action. +Cache clearing uses an explicit cold filesystem allowlist; it never clears durable +storage or follows redirecting links. Explicit environment-controlled profiles keep +the existing override and opt-out locking semantics, and disable these actions. + +The entry module completes synchronous identity/locking, then asynchronously +prepares storage and imports the composition root. It does not top-level-await +app.whenReady: Electron gates readiness on main-module evaluation. Normal startup +uses whenReady promises so importing after readiness still installs services. + +## Alternatives + +- Live relocation: rejected because SQLite, plugin processes, browser partitions, + outbox and logs do not share an atomic live-switch boundary. +- Cache-only relocation: does not satisfy the requested complete storage migration. +- Move userData and its lock: changes installation identity and admits competing + launches while migrating. Keeping the small bootstrap anchor avoids that change. +- Rewrite every matching byte/string: would corrupt credentials, user commands, + code, prose, Chromium databases and unknown plugin-owned formats. + +## Consequences + +The default profile and Rust ownership remain compatible. Migration needs room for +one full verified copy plus metadata; originals consume space until the user +checks the migrated installation and separately removes backups. Source data is +untouched on copy/relocation failures. A missing custom volume blocks startup with +a recovery message rather than creating a misleading empty profile. + +Unknown plugin-private absolute references and historical narrative paths retain +their bytes; extension authors own their portability. Backup deletion warns users +to check these references. Ordinary external project directories are never moved. +No schema/protocol version bump or plugin SDK change is required; storage IPC is +additive, main-window-only, and preferences stay machine-local. diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index c105ba959a..5b7aacc16f 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -47,6 +47,62 @@ read time; their path-scoped memory and filesystem instructions remain readable. ## 2. File layout +### User-selected storage location (issue #1213) + +Settings → General → Storage can select an empty directory on a different +volume. A selected directory contains `data/` (the complete host/application +profile) and `browser/` (Chromium default and persistent plugin/browser session +state). The existing default directories remain unchanged until the user +explicitly migrates. Project files outside the application profile are not moved. + +The original Electron `userData` directory remains the installation identity, +single-instance lock, and owner-only `storage-location.json` bootstrap anchor. +Chromium `sessionData` follows `browser/`; this preserves existing localStorage, +cookies, IndexedDB and persistent partition state by copying the complete old +profile. An explicit `PI_DESKTOP_DATA_DIR` still overrides the default and disables +settings-driven maintenance, since such profiles opt out of the installation lock. +A managed relaunch discards only the environment root published for child services +through the internal `--pi-managed-storage` argument before reacquiring the lock. +The location is machine-local and never part of cloud configuration sync. + +Migration is cold: the accepted settings action journals pending work, then uses +existing ordered shutdown to settle turns/outbox and stop writers. The next launch +opens only a sandboxed, nonpersistent maintenance window before importing the +application composition root. It inventories bytes/files, checks free space, streams +the copy, preserves permissions and internal/external links, and SHA-256 verifies +both source and copied files. An interrupted copy may be retried only with its +matching ownership marker; nonempty/unrelated destinations and overlapping roots +are rejected. The stable installation lock prevents competing managed launches. + +Rust's offline `--relocate-data ` mode owns structured +path relocation in the copied SQLite index, transcripts/revisions/checkpoints, +outbox, installed plugin registry and agent capability metadata. It does not boot +RPC, upgrade schemas, recover turns, or sweep scratch. It changes only known +path-bearing fields under the old root. External projects, dev/builtin plugins, +narrative text, commands, source code, secrets, and arbitrary plugin-private formats +are preserved. Credentials and their machine key migrate as bytes with their +permissions. SQLite ownership stays exclusively in Rust. + +Only after validation/relocation succeeds is the flushed bootstrap pointer +atomically replaced. Errors keep the old profile active and visible in settings; +retrying the same destination uses the failed job's ownership identity. A crash +before publication leaves pending work to recopy from the source. An unavailable +selected volume refuses startup rather than creating a blank profile elsewhere. +Original directories remain explicit backups. Deleting these requires a separate +settings confirmation after checking new-location functionality, including plugins +that may own absolute references the host cannot safely rewrite. Backup cleanup +preflights every root and protects active storage and bootstrap/lock files. + +Cache cleanup is a separate confirmed cold-restart operation. Its filesystem +allowlist is `cache/`, `plugins/cache/download/`, `plugins/cache/backup/`, +`openable-attachments/`, and Chromium's Cache/Code Cache/GPU/shader cache +folders in the default profile and persistent partitions. Intermediate or leaf +symlinks cannot redirect cleanup, even within the same profile. It never clears +cookies/localStorage/IndexedDB, transcripts, attachments, secrets, scratch, +review snapshots, plugin code/data, models, configuration, or logs. Partial cleanup +failure remains observable, retains active roots, and can be retried. + + A packaged installation keeps this tree in `~/.pi-desktop`. A development build keeps the same tree in `~/.pi-desktop-dev`, because a shipped app and a `pnpm dev` host are two installations that have to run at the same time (D599, diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md index 68c4f59d8f..61bf0c25b3 100644 --- a/docs/spec/04-ux/06-settings-ia.md +++ b/docs/spec/04-ux/06-settings-ia.md @@ -100,6 +100,25 @@ Settings is a **full-window page** that replaces the app sidebar + main chrome ( ## 2. Section contents ### General + +- **Storage** shows the effective application data path and the reclaimable cache + size. It is included in settings search. Choose directory uses the native picker; + confirmation displays both application and browser source paths, the target, + migration scope and the restart/backup policy. Cancelling has no side effects. + Accepting locks repeat actions until the app exits through ordered shutdown. +- A separate sandboxed cold-maintenance window shows localized scanning, copying, + verifying and internal-path relocation stages, file counts, copied/verified bytes + and determinate progress where known. It does not load plugins or agent services. + Failure explains that the original profile remains active, then returns to it; + settings exposes the error and permits retry. A disconnected selected volume + blocks startup with a recovery explanation instead of silently using empty data. +- **Clear cache** shows a size and requires an inline confirmation describing the + retained durable data before clearing and restarting. **Delete old backups** is + separate, lists original paths and warns users to check their plugins and old + attachments first. Arbitrary plugin-owned absolute references cannot be rewritten + by the host. Environment-controlled profiles display why maintenance is disabled. + Confirmation gets keyboard focus, asynchronous errors remain visible, and all + visible copy is localized. These operations affect only this local installation. - **Appearance** card: - **Theme**: a searchable picker row (same anchored-menu pattern as Language). The closed trigger sizes to the current label, capped by the diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 3d05eee2d4..74f85b7ec9 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -8,6 +8,35 @@ ## 1. Goals +### E2E-STORAGE-custom-location-and-maintenance + +- **Preconditions:** Dedicated request worktree, current remote-main base, shared + compatible host toolchain, built host-core and desktop, isolated temporary data + and Chromium profiles. No user's running desktop, provider, or network is used. +- **Steps:** Through the production Settings page, choose/cancel a destination, + confirm/retry migration, and confirm cache/backup cleanup. Seed a real host with + sessions, queued attachments, project metadata, credentials and a local installed + plugin. Stop it, copy with the production migration service, invoke the real Rust + offline relocation process, and reopen the host. Seed default/persistent Chromium + localStorage, execute the production cold bootstrap in real Electron, restart with + its pointer, clear caches, and then separately remove old backups. +- **Expected:** Data and browser source paths are visible, confirmation receives + focus, duplicate actions are locked, environment overrides disable changes and + failures allow recovery. Copy/verification/path-relocation progress is localized + and uses a sandboxed nonpersistent window. Host data, credential decryption, + installed plugin locations, main localStorage and plugin persistent storage + survive migration/restart. Cache cleanup preserves durable state; backup cleanup + retains active data and the stable bootstrap pointer. Filesystem regression tests + reject unsafe targets, redirecting links, wrong ownership and active-root overlap. +- **Coverage:** `pnpm test:e2e:storage` runs `e2e-storage-settings.mjs`, + `e2e-storage-migration.mjs`, and `e2e-storage-bootstrap.mjs`. The first uses the + production renderer/API with only preload mocked; the latter suites run real + host/Electron processes and production maintenance. The bootstrap harness + intercepts relaunch to inspect its result, then explicitly starts another isolated + child; it does not launch the user's desktop. `storage-maintenance.test.mjs` + and Rust `data_relocation` tests cover rollback and path/filesystem boundaries. + + ### E2E-LIVE-VOICE-public-settings-and-reconnect - **Preconditions:** A built production Renderer and real Electron/Main/Host, diff --git a/package.json b/package.json index 1b91717a00..cb4b780e6b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test": "pnpm build:js && pnpm -r --if-present test && cargo test -p host-core", "test:host": "cargo test -p host-core", "test:e2e": "node scripts/e2e-smoke.mjs", + "test:e2e:storage": "node scripts/e2e-storage-settings.mjs && node scripts/e2e-storage-migration.mjs && node scripts/e2e-storage-bootstrap.mjs", "test:e2e:scheduled": "node scripts/e2e-scheduled.mjs", "test:e2e:keep-awake": "node scripts/e2e-keep-awake.mjs", "test:e2e:live-voice": "node scripts/e2e-live-voice.mjs", diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index e810871314..05dd344eea 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -606,6 +606,50 @@ export const de = { "dismiss": "Verwerfen" }, "settings": { + storage: { + unavailableHint: "Das Speicherverzeichnis ist derzeit nicht verfügbar. Verbinden Sie das Laufwerk vor dem Start erneut. Die App erstellt keine leeren Daten und wechselt nicht in ein anderes Verzeichnis.", + browserSource: "Quelle der Browserdaten", + progressTitle: "Speicher wird vorbereitet", + progressHint: "Lassen Sie dieses Fenster während der Verarbeitung geöffnet.", + failedTitle: "Speichervorgang fehlgeschlagen", + failedHint: "Ihre vorhandenen Daten sind sicher. Verwenden Sie den aktuellen Speicherort und versuchen Sie es in den Einstellungen erneut.", + continueOriginal: "Aktuellen Speicherort verwenden", + stages: { + scanning: "Dateien scannen", + copying: "Daten kopieren", + verifying: "Daten prüfen", + relocating: "Gespeicherte Pfade aktualisieren", + cleaning: "Cache leeren", + complete: "Abgeschlossen", + failed: "Fehlgeschlagen", + }, + title: "Speicher", + dataPath: "Datenspeicherort", + scope: "Umfasst app-eigene Chats, Anhänge, Einstellungen, Zugangsdaten, Plugins, Skills, Modelle und Browserdaten. Projektquellordner bleiben unverändert.", + choose: "Speicherort ändern…", + cache: "Löschbarer Cache", + cacheScope: "Nur neu erzeugbare Caches werden gelöscht. Chats, Anhänge, Arbeitsdateien, Einstellungen, Zugangsdaten, Plugin-Daten und heruntergeladene Modelle bleiben erhalten.", + clearCache: "Cache leeren…", + backup: "Vorherige Datensicherungen", + backupHint: "Die ursprünglichen Daten bleiben nach der Migration hier und belegen weiter Speicherplatz. Erst nach Prüfung des neuen Speicherorts löschen. Ein Neustart ist erforderlich. Absolute Pfade in Plugin-Konfigurationen werden nicht automatisch angepasst. Prüfen Sie vor dem Löschen der Sicherungen, ob Plugins und ältere Anhänge am neuen Speicherort funktionieren.", + removeBackup: "Sicherungen entfernen…", + environmentManaged: "Der Speicherort wird durch eine Umgebungsvariable gesteuert. Ändern Sie die entsprechende Konfiguration.", + pending: "Die Migration nach {{path}} ist für den nächsten Neustart geplant.", + lastError: "Der vorherige Speichervorgang ist fehlgeschlagen; der aktive Speicherort wurde nicht geändert. Sie können es erneut versuchen. {{error}}", + retry: "Erneut versuchen", + migrateTitle: "Vorhandene Daten verschieben", + cacheTitle: "Neu erzeugbaren Cache leeren", + backupTitle: "Vorherige Sicherungen entfernen", + source: "Aktueller Speicherort", + destination: "Neuer Speicherort", + migrateHint: "Wählen Sie einen leeren, beschreibbaren Ordner. Nach dem Neustart werden Daten kopiert und geprüft. Ein Migrationsfenster zeigt den Fortschritt. Die Originaldaten bleiben als Sicherung.", + migrateRestart: "Migrieren und neu starten", + cacheHint: "Etwa {{size}} neu erzeugbaren Cache leeren und neu starten. Chats, Anhänge, Arbeitsdateien, Einstellungen, Zugangsdaten, Plugin-Daten und Modelle bleiben erhalten.", + cacheRestart: "Leeren und neu starten", + backupRestart: "Entfernen und neu starten", + restarting: "Neustart läuft…", + operationError: "Der Speichervorgang konnte nicht gestartet werden. {{error}}", + }, "power": "Energie", "keepAwakeWhileRunning": "Computer wach halten", "keepAwakeWhileRunningDesc": "Verhindert den Ruhezustand bei Inaktivität, solange PI-Desktop läuft. Der Bildschirm kann sich ausschalten; manuelles Schlafen und Zuklappen bleiben möglich.", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 73d5536f67..f75625db36 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -618,6 +618,50 @@ export const en = { dismiss: "Dismiss", }, settings: { + storage: { + unavailableHint: "The storage directory is currently unavailable. Reconnect the drive before starting; the app will not create empty data or switch to another directory.", + browserSource: "Browser data source", + progressTitle: "Preparing your storage", + progressHint: "Keep this window open while your data is processed.", + failedTitle: "Storage operation failed", + failedHint: "Your existing data remains safe. Continue using the current location and retry from Settings.", + continueOriginal: "Continue with current location", + stages: { + scanning: "Scanning files", + copying: "Copying data", + verifying: "Verifying data", + relocating: "Updating stored paths", + cleaning: "Cleaning cache", + complete: "Complete", + failed: "Failed", + }, + title: "Storage", + dataPath: "Data location", + scope: "Includes app-owned chats, attachments, settings, credentials, plugins, skills, models and browser data. Project source folders stay where they are.", + choose: "Change location…", + cache: "Reclaimable cache", + cacheScope: "Only regenerable caches are removed. Chats, attachments, scratch files, settings, credentials, plugin data and downloaded models are preserved.", + clearCache: "Clear cache…", + backup: "Previous data backups", + backupHint: "Original data stays in these directories after migration and continues to use disk space. Remove it only after verifying your new location. This requires a restart. Absolute paths in plugin-owned configuration are not rewritten automatically. Before removing backups, verify that your plugins and historical attachments work in the new location.", + removeBackup: "Remove backups…", + environmentManaged: "Storage is controlled by an environment variable. Change that configuration to manage its location.", + pending: "Migration to {{path}} is scheduled for the next restart.", + lastError: "The previous storage operation failed; your active location was not changed. You can retry. {{error}}", + retry: "Retry", + migrateTitle: "Move existing data", + cacheTitle: "Clear regenerable cache", + backupTitle: "Remove previous backups", + source: "Current location", + destination: "New location", + migrateHint: "Choose an empty, writable folder. The app will restart, copy and verify existing data, and show progress in a migration window. The original data remains as a backup.", + migrateRestart: "Migrate and restart", + cacheHint: "Clear approximately {{size}} of regenerable cache and restart. Chats, attachments, scratch files, settings, credentials, plugin data and models are preserved.", + cacheRestart: "Clear and restart", + backupRestart: "Remove and restart", + restarting: "Restarting…", + operationError: "Could not start the storage operation. {{error}}", + }, power: "Power", keepAwakeWhileRunning: "Keep computer awake", keepAwakeWhileRunningDesc: "Prevent idle system sleep while PI-Desktop is running. The display may turn off; manual sleep and closing the lid still work.", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 67de6a28e1..aab7a0dd8e 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -606,6 +606,50 @@ export const es = { "dismiss": "Descartar" }, "settings": { + storage: { + unavailableHint: "El directorio de almacenamiento no está disponible. Vuelve a conectar la unidad antes de iniciar; la aplicación no creará datos vacíos ni cambiará a otro directorio.", + browserSource: "Origen de los datos del navegador", + progressTitle: "Preparando el almacenamiento", + progressHint: "Mantén esta ventana abierta mientras se procesan tus datos.", + failedTitle: "Error de almacenamiento", + failedHint: "Tus datos existentes siguen seguros. Continúa con la ubicación actual y reintenta desde Ajustes.", + continueOriginal: "Continuar con la ubicación actual", + stages: { + scanning: "Analizando archivos", + copying: "Copiando datos", + verifying: "Verificando datos", + relocating: "Actualizando rutas guardadas", + cleaning: "Limpiando caché", + complete: "Completado", + failed: "Error", + }, + title: "Almacenamiento", + dataPath: "Ubicación de los datos", + scope: "Incluye chats, adjuntos, ajustes, credenciales, plugins, habilidades, modelos y datos del navegador propios de la aplicación. Los proyectos permanecen donde están.", + choose: "Cambiar ubicación…", + cache: "Caché recuperable", + cacheScope: "Solo se elimina la caché regenerable. Se conservan chats, adjuntos, archivos de trabajo temporales, ajustes, credenciales, datos de plugins y modelos descargados.", + clearCache: "Limpiar caché…", + backup: "Copias de datos anteriores", + backupHint: "Los datos originales permanecen aquí tras la migración y siguen ocupando espacio. Elimínalos solo después de verificar la nueva ubicación. Se requiere reiniciar. Las rutas absolutas de la configuración de los plugins no se reescriben automáticamente. Antes de eliminar las copias, verifica que los plugins y los adjuntos anteriores funcionen en la nueva ubicación.", + removeBackup: "Eliminar copias…", + environmentManaged: "Una variable de entorno controla la ubicación. Modifica esa configuración para administrarla.", + pending: "La migración a {{path}} está programada para el próximo reinicio.", + lastError: "La operación anterior falló; la ubicación activa no cambió. Puedes volver a intentarlo. {{error}}", + retry: "Reintentar", + migrateTitle: "Migrar datos existentes", + cacheTitle: "Limpiar caché regenerable", + backupTitle: "Eliminar copias anteriores", + source: "Ubicación actual", + destination: "Nueva ubicación", + migrateHint: "Elige una carpeta vacía con permiso de escritura. La aplicación se reiniciará, copiará y verificará los datos, mostrando el progreso en una ventana de migración. Los originales se conservan como copia.", + migrateRestart: "Migrar y reiniciar", + cacheHint: "Limpiar aproximadamente {{size}} de caché regenerable y reiniciar. Se conservan chats, adjuntos, archivos de trabajo, ajustes, credenciales, datos de plugins y modelos.", + cacheRestart: "Limpiar y reiniciar", + backupRestart: "Eliminar y reiniciar", + restarting: "Reiniciando…", + operationError: "No se pudo iniciar la operación de almacenamiento. {{error}}", + }, "power": "Energía", "keepAwakeWhileRunning": "Mantener el equipo activo", "keepAwakeWhileRunningDesc": "Evita la suspensión por inactividad mientras PI-Desktop esté abierto. La pantalla puede apagarse; la suspensión manual y al cerrar la tapa siguen funcionando.", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 0d62e9e76c..54ffaccd8d 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -606,6 +606,50 @@ export const fr = { "dismiss": "Ignorer" }, "settings": { + storage: { + unavailableHint: "Le dossier de stockage est actuellement indisponible. Reconnectez le disque avant de démarrer ; l’application ne créera pas de données vides et ne changera pas de dossier.", + browserSource: "Source des données du navigateur", + progressTitle: "Préparation du stockage", + progressHint: "Gardez cette fenêtre ouverte pendant le traitement des données.", + failedTitle: "Échec de l’opération de stockage", + failedHint: "Vos données existantes restent sûres. Continuez avec l’emplacement actuel et réessayez depuis les paramètres.", + continueOriginal: "Continuer avec l’emplacement actuel", + stages: { + scanning: "Analyse des fichiers", + copying: "Copie des données", + verifying: "Vérification des données", + relocating: "Mise à jour des chemins enregistrés", + cleaning: "Nettoyage du cache", + complete: "Terminé", + failed: "Échec", + }, + title: "Stockage", + dataPath: "Emplacement des données", + scope: "Comprend les conversations, pièces jointes, paramètres, identifiants, plugins, compétences, modèles et données du navigateur de l’application. Les dossiers sources des projets restent en place.", + choose: "Changer d’emplacement…", + cache: "Cache récupérable", + cacheScope: "Seuls les caches régénérables sont supprimés. Conversations, pièces jointes, fichiers de travail, paramètres, identifiants, données des plugins et modèles téléchargés sont conservés.", + clearCache: "Vider le cache…", + backup: "Sauvegardes précédentes", + backupHint: "Les données originales restent ici après la migration et occupent toujours de l’espace. Supprimez-les après avoir vérifié le nouvel emplacement. Un redémarrage est nécessaire. Les chemins absolus des configurations propres aux plugins ne sont pas réécrits automatiquement. Avant de supprimer les sauvegardes, vérifiez que les plugins et les anciennes pièces jointes fonctionnent au nouvel emplacement.", + removeBackup: "Supprimer les sauvegardes…", + environmentManaged: "Une variable d’environnement contrôle cet emplacement. Modifiez la configuration correspondante.", + pending: "La migration vers {{path}} est prévue au prochain redémarrage.", + lastError: "L’opération précédente a échoué ; l’emplacement actif est inchangé. Vous pouvez réessayer. {{error}}", + retry: "Réessayer", + migrateTitle: "Migrer les données existantes", + cacheTitle: "Vider le cache régénérable", + backupTitle: "Supprimer les anciennes sauvegardes", + source: "Emplacement actuel", + destination: "Nouvel emplacement", + migrateHint: "Choisissez un dossier vide et accessible en écriture. L’application redémarrera, copiera et vérifiera les données. Une fenêtre affichera la progression. Les données originales restent sauvegardées.", + migrateRestart: "Migrer et redémarrer", + cacheHint: "Vider environ {{size}} de cache régénérable et redémarrer. Conversations, pièces jointes, fichiers de travail, paramètres, identifiants, données des plugins et modèles sont conservés.", + cacheRestart: "Vider et redémarrer", + backupRestart: "Supprimer et redémarrer", + restarting: "Redémarrage…", + operationError: "Impossible de lancer l’opération de stockage. {{error}}", + }, "power": "Alimentation", "keepAwakeWhileRunning": "Garder l'ordinateur éveillé", "keepAwakeWhileRunningDesc": "Empêche la veille due à l'inactivité pendant l'exécution de PI-Desktop. L'écran peut s'éteindre ; la veille manuelle et la fermeture du capot restent possibles.", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 14ad6c0f81..24593e38be 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -615,6 +615,50 @@ export const ko = { dismiss: "닫기", }, settings: { + storage: { + unavailableHint: "저장 폴더를 현재 사용할 수 없습니다. 드라이브를 다시 연결한 후 시작하세요. 앱은 빈 데이터를 생성하거나 다른 폴더로 전환하지 않습니다.", + browserSource: "브라우저 데이터 원본", + progressTitle: "저장소 준비 중", + progressHint: "데이터를 처리하는 동안 이 창을 열어 두세요.", + failedTitle: "저장 작업 실패", + failedHint: "기존 데이터는 안전합니다. 현재 위치를 계속 사용하고 설정에서 다시 시도하세요.", + continueOriginal: "현재 위치에서 계속", + stages: { + scanning: "파일 검색 중", + copying: "데이터 복사 중", + verifying: "데이터 검증 중", + relocating: "저장된 경로 갱신 중", + cleaning: "캐시 정리 중", + complete: "완료", + failed: "실패", + }, + title: "저장소", + dataPath: "데이터 저장 위치", + scope: "앱의 대화, 첨부 파일, 설정, 자격 증명, 플러그인, 스킬, 모델 및 브라우저 데이터가 포함됩니다. 프로젝트 소스 폴더는 현재 위치에 유지됩니다.", + choose: "위치 변경…", + cache: "정리 가능한 캐시", + cacheScope: "다시 생성할 수 있는 캐시만 삭제합니다. 대화, 첨부 파일, 임시 작업 파일, 설정, 자격 증명, 플러그인 데이터 및 다운로드한 모델은 보존합니다.", + clearCache: "캐시 정리…", + backup: "이전 데이터 백업", + backupHint: "이전 데이터는 이전 후에도 이 폴더에 남아 디스크 공간을 사용합니다. 새 위치를 확인한 후 삭제하세요. 삭제하려면 다시 시작해야 합니다. 플러그인 자체 설정의 절대 경로는 자동으로 변경되지 않습니다. 이전 백업을 삭제하기 전에 새 위치에서 플러그인과 과거 첨부 파일이 정상 작동하는지 확인하세요.", + removeBackup: "백업 삭제…", + environmentManaged: "환경 변수가 저장 위치를 제어합니다. 해당 구성을 변경하여 위치를 관리하세요.", + pending: "다음 재시작 시 {{path}}로 이전할 예정입니다.", + lastError: "이전 저장 작업에 실패했습니다. 현재 위치는 변경되지 않았으며 다시 시도할 수 있습니다. {{error}}", + retry: "다시 시도", + migrateTitle: "기존 데이터 이전", + cacheTitle: "재생성 가능한 캐시 정리", + backupTitle: "이전 백업 삭제", + source: "현재 위치", + destination: "새 위치", + migrateHint: "비어 있고 쓰기 가능한 폴더를 선택하세요. 앱이 다시 시작하여 데이터를 복사하고 검증하며 이전 창에 진행률을 표시합니다. 원본은 백업으로 남습니다.", + migrateRestart: "이전 후 다시 시작", + cacheHint: "약 {{size}}의 재생성 가능한 캐시를 정리하고 다시 시작합니다. 대화, 첨부 파일, 임시 작업 파일, 설정, 자격 증명, 플러그인 데이터 및 모델은 보존합니다.", + cacheRestart: "정리 후 다시 시작", + backupRestart: "삭제 후 다시 시작", + restarting: "다시 시작 중…", + operationError: "저장 작업을 시작할 수 없습니다. {{error}}", + }, power: "전원", keepAwakeWhileRunning: "컴퓨터 절전 방지", keepAwakeWhileRunningDesc: "PI-Desktop 실행 중 유휴 상태로 인한 시스템 절전을 방지합니다. 화면은 꺼질 수 있으며 수동 절전과 덮개 닫기는 그대로 작동합니다.", diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index eba81c34f9..4b1fa0c6bc 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -604,6 +604,50 @@ export const ptBR = { dismiss: "Dispensar" }, settings: { + storage: { + unavailableHint: "O diretório de armazenamento está indisponível no momento. Reconecte a unidade antes de iniciar; o aplicativo não criará dados vazios nem mudará para outro diretório.", + browserSource: "Origem dos dados do navegador", + progressTitle: "Preparando o armazenamento", + progressHint: "Mantenha esta janela aberta enquanto seus dados são processados.", + failedTitle: "Falha na operação de armazenamento", + failedHint: "Seus dados existentes permanecem seguros. Continue usando o local atual e tente novamente nas Configurações.", + continueOriginal: "Continuar com o local atual", + stages: { + scanning: "Verificando arquivos", + copying: "Copiando dados", + verifying: "Validando dados", + relocating: "Atualizando caminhos salvos", + cleaning: "Limpando cache", + complete: "Concluído", + failed: "Falhou", + }, + title: "Armazenamento", + dataPath: "Local dos dados", + scope: "Inclui conversas, anexos, configurações, credenciais, plugins, habilidades, modelos e dados do navegador do aplicativo. As pastas dos projetos permanecem onde estão.", + choose: "Alterar local…", + cache: "Cache recuperável", + cacheScope: "Somente caches que podem ser regenerados são removidos. Conversas, anexos, arquivos temporários de trabalho, configurações, credenciais, dados de plugins e modelos baixados são preservados.", + clearCache: "Limpar cache…", + backup: "Backups anteriores dos dados", + backupHint: "Os dados originais permanecem aqui após a migração e continuam ocupando espaço. Remova-os após verificar o novo local. É necessário reiniciar. Os caminhos absolutos nas configurações dos plugins não são reescritos automaticamente. Antes de remover os backups, verifique se os plugins e os anexos anteriores funcionam no novo local.", + removeBackup: "Remover backups…", + environmentManaged: "Uma variável de ambiente controla o armazenamento. Altere essa configuração para gerenciar o local.", + pending: "A migração para {{path}} está agendada para a próxima reinicialização.", + lastError: "A operação anterior falhou; o local ativo não foi alterado. Você pode tentar novamente. {{error}}", + retry: "Tentar novamente", + migrateTitle: "Migrar dados existentes", + cacheTitle: "Limpar cache regenerável", + backupTitle: "Remover backups anteriores", + source: "Local atual", + destination: "Novo local", + migrateHint: "Escolha uma pasta vazia com permissão de gravação. O aplicativo reiniciará, copiará e verificará os dados, exibindo o progresso em uma janela de migração. Os originais ficam como backup.", + migrateRestart: "Migrar e reiniciar", + cacheHint: "Limpar aproximadamente {{size}} de cache regenerável e reiniciar. Conversas, anexos, arquivos de trabalho, configurações, credenciais, dados de plugins e modelos são preservados.", + cacheRestart: "Limpar e reiniciar", + backupRestart: "Remover e reiniciar", + restarting: "Reiniciando…", + operationError: "Não foi possível iniciar a operação de armazenamento. {{error}}", + }, power: "Energia", keepAwakeWhileRunning: "Manter o computador ativo", keepAwakeWhileRunningDesc: "Impede a suspensão por inatividade enquanto o PI-Desktop estiver aberto. A tela pode apagar; a suspensão manual e ao fechar a tampa continuam funcionando.", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 84fd5b68a8..86084b0648 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -615,6 +615,50 @@ export const tr = { dismiss: "Kapat", }, settings: { + storage: { + unavailableHint: "Depolama dizini şu anda kullanılamıyor. Başlatmadan önce sürücüyü yeniden bağlayın; uygulama boş veri oluşturmaz veya başka bir dizine geçmez.", + browserSource: "Tarayıcı verisi kaynağı", + progressTitle: "Depolama hazırlanıyor", + progressHint: "Verileriniz işlenirken bu pencereyi açık tutun.", + failedTitle: "Depolama işlemi başarısız", + failedHint: "Mevcut verileriniz güvende. Mevcut konumu kullanmaya devam edin ve Ayarlar üzerinden yeniden deneyin.", + continueOriginal: "Mevcut konumla devam et", + stages: { + scanning: "Dosyalar taranıyor", + copying: "Veriler kopyalanıyor", + verifying: "Veriler doğrulanıyor", + relocating: "Kayıtlı yollar güncelleniyor", + cleaning: "Önbellek temizleniyor", + complete: "Tamamlandı", + failed: "Başarısız", + }, + title: "Depolama", + dataPath: "Veri konumu", + scope: "Uygulamaya ait sohbetler, ekler, ayarlar, kimlik bilgileri, eklentiler, beceriler, modeller ve tarayıcı verilerini içerir. Proje kaynak klasörleri yerinde kalır.", + choose: "Konumu değiştir…", + cache: "Temizlenebilir önbellek", + cacheScope: "Yalnızca yeniden oluşturulabilen önbellekler silinir. Sohbetler, ekler, geçici çalışma dosyaları, ayarlar, kimlik bilgileri, eklenti verileri ve indirilen modeller korunur.", + clearCache: "Önbelleği temizle…", + backup: "Önceki veri yedekleri", + backupHint: "Asıl veriler taşımadan sonra burada kalır ve disk alanı kullanmaya devam eder. Yeni konumu doğruladıktan sonra silin. Yeniden başlatma gerekir. Eklentilere ait yapılandırmalardaki mutlak yollar otomatik olarak yeniden yazılmaz. Yedekleri silmeden önce eklentilerin ve eski eklerin yeni konumda çalıştığını doğrulayın.", + removeBackup: "Yedekleri sil…", + environmentManaged: "Depolama bir ortam değişkeniyle yönetiliyor. Konumu yönetmek için ilgili yapılandırmayı değiştirin.", + pending: "{{path}} konumuna taşıma sonraki yeniden başlatmada yapılacak.", + lastError: "Önceki depolama işlemi başarısız oldu; etkin konum değiştirilmedi. Yeniden deneyebilirsiniz. {{error}}", + retry: "Yeniden dene", + migrateTitle: "Mevcut verileri taşı", + cacheTitle: "Yeniden oluşturulabilir önbelleği temizle", + backupTitle: "Önceki yedekleri sil", + source: "Mevcut konum", + destination: "Yeni konum", + migrateHint: "Boş ve yazılabilir bir klasör seçin. Uygulama yeniden başlayacak, verileri kopyalayıp doğrulayacak ve taşıma penceresinde ilerlemeyi gösterecek. Asıl veriler yedek olarak kalır.", + migrateRestart: "Taşı ve yeniden başlat", + cacheHint: "Yaklaşık {{size}} yeniden oluşturulabilir önbelleği temizleyip yeniden başlatın. Sohbetler, ekler, çalışma dosyaları, ayarlar, kimlik bilgileri, eklenti verileri ve modeller korunur.", + cacheRestart: "Temizle ve yeniden başlat", + backupRestart: "Sil ve yeniden başlat", + restarting: "Yeniden başlatılıyor…", + operationError: "Depolama işlemi başlatılamadı. {{error}}", + }, power: "Güç", keepAwakeWhileRunning: "Bilgisayarı uyanık tut", keepAwakeWhileRunningDesc: "PI-Desktop çalışırken boşta kalma nedeniyle uykuya geçmeyi önler. Ekran kapanabilir; elle uyutma ve kapağı kapatma etkilenmez.", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index c0f1141b10..e297bc6c8e 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -610,6 +610,50 @@ export const zhCN = { dismiss: "关闭", }, settings: { + storage: { + unavailableHint: "保存目录暂时不可用。请重新连接磁盘后启动;应用不会创建空白数据或切换到其他目录。", + browserSource: "浏览器数据来源", + progressTitle: "正在处理存储数据", + progressHint: "处理数据期间请保持此窗口打开。", + failedTitle: "存储操作失败", + failedHint: "已有数据仍然安全,可以继续使用当前目录,并在设置中重试。", + continueOriginal: "继续使用当前目录", + stages: { + scanning: "扫描文件", + copying: "复制数据", + verifying: "校验数据", + relocating: "更新已保存路径", + cleaning: "清理缓存", + complete: "已完成", + failed: "失败", + }, + title: "存储", + dataPath: "数据保存路径", + scope: "包含应用自己的会话、附件、设置、凭据、插件、技能、模型和浏览器数据。项目源代码目录保持原位置。", + choose: "更改路径…", + cache: "可清理缓存", + cacheScope: "仅删除可重新生成的缓存。保留会话、附件、临时工作文件、设置、凭据、插件数据和已下载模型。", + clearCache: "清理缓存…", + backup: "原数据备份", + backupHint: "迁移后原数据保留在这些目录中,继续占用原磁盘空间。确认新目录的数据正常后再删除,删除需要重启。 插件自有配置中的绝对路径不会自动重写。删除旧备份前,请确认插件和历史附件在新位置正常。", + removeBackup: "删除旧备份…", + environmentManaged: "保存路径由环境变量控制,请修改对应配置后再管理路径。", + pending: "已安排在下次重启时迁移至 {{path}}。", + lastError: "上次存储操作失败,当前保存路径未更改,可以重试。{{error}}", + retry: "重试", + migrateTitle: "迁移已有数据", + cacheTitle: "清理可重新生成的缓存", + backupTitle: "删除原数据备份", + source: "当前路径", + destination: "目标路径", + migrateHint: "请选择空的可写目录。应用将重启,复制并校验已有数据,并在迁移窗口显示进度。原数据保留为备份。", + migrateRestart: "迁移并重启", + cacheHint: "将清理约 {{size}} 的可重新生成缓存并重启。保留会话、附件、临时工作文件、设置、凭据、插件数据和模型。", + cacheRestart: "清理并重启", + backupRestart: "删除并重启", + restarting: "正在重启…", + operationError: "无法启动存储操作。{{error}}", + }, power: "电源", keepAwakeWhileRunning: "保持电脑唤醒", keepAwakeWhileRunningDesc: "PI-Desktop 运行期间阻止电脑因空闲自动休眠。屏幕仍可能关闭;手动睡眠和合盖休眠不受影响。", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 46afa84c3c..ddb54924dc 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -610,6 +610,50 @@ export const zhTW = { dismiss: "關閉", }, settings: { + storage: { + unavailableHint: "儲存目錄暫時無法使用。請重新連接磁碟後啟動;應用程式不會建立空白資料或切換到其他目錄。", + browserSource: "瀏覽器資料來源", + progressTitle: "正在處理儲存資料", + progressHint: "處理資料期間請保持此視窗開啟。", + failedTitle: "儲存操作失敗", + failedHint: "現有資料仍然安全,可以繼續使用目前目錄,並在設定中重試。", + continueOriginal: "繼續使用目前目錄", + stages: { + scanning: "掃描檔案", + copying: "複製資料", + verifying: "驗證資料", + relocating: "更新已儲存路徑", + cleaning: "清理快取", + complete: "已完成", + failed: "失敗", + }, + title: "儲存空間", + dataPath: "資料儲存路徑", + scope: "包含應用程式自己的對話、附件、設定、憑證、外掛、技能、模型和瀏覽器資料。專案原始碼目錄維持原位置。", + choose: "變更路徑…", + cache: "可清理快取", + cacheScope: "僅刪除可重新產生的快取。保留對話、附件、暫存工作檔案、設定、憑證、外掛資料和已下載模型。", + clearCache: "清理快取…", + backup: "原資料備份", + backupHint: "遷移後原資料保留在這些目錄中,繼續佔用原磁碟空間。確認新目錄的資料正常後再刪除,刪除需要重新啟動。 外掛自有設定中的絕對路徑不會自動重寫。刪除舊備份前,請確認外掛和歷史附件在新位置正常。", + removeBackup: "刪除舊備份…", + environmentManaged: "儲存路徑由環境變數控制,請修改對應設定後再管理路徑。", + pending: "已排定在下次重新啟動時遷移至 {{path}}。", + lastError: "上次儲存操作失敗,目前儲存路徑未變更,可以重試。{{error}}", + retry: "重試", + migrateTitle: "遷移現有資料", + cacheTitle: "清理可重新產生的快取", + backupTitle: "刪除原資料備份", + source: "目前路徑", + destination: "目標路徑", + migrateHint: "請選擇空的可寫入目錄。應用程式將重新啟動,複製並驗證現有資料,並在遷移視窗顯示進度。原資料保留為備份。", + migrateRestart: "遷移並重新啟動", + cacheHint: "將清理約 {{size}} 的可重新產生快取並重新啟動。保留對話、附件、暫存工作檔案、設定、憑證、外掛資料和模型。", + cacheRestart: "清理並重新啟動", + backupRestart: "刪除並重新啟動", + restarting: "正在重新啟動…", + operationError: "無法啟動儲存操作。{{error}}", + }, power: "電源", keepAwakeWhileRunning: "保持電腦喚醒", keepAwakeWhileRunningDesc: "PI-Desktop 執行期間阻止電腦因閒置自動休眠。螢幕仍可能關閉;手動睡眠和闔蓋休眠不受影響。", diff --git a/packages/i18n/test/catalogs.test.mjs b/packages/i18n/test/catalogs.test.mjs index 4d42dabfa6..29fd69b661 100644 --- a/packages/i18n/test/catalogs.test.mjs +++ b/packages/i18n/test/catalogs.test.mjs @@ -21,6 +21,25 @@ function placeholders(value) { const english = flattenCatalog(en); +test("offline storage maintenance has localized progress and recovery copy before renderer startup", () => { + const fields = ["progressTitle", "progressHint", "failedTitle", "failedHint", "continueOriginal", "unavailableHint"]; + const stages = ["scanning", "copying", "verifying", "relocating", "cleaning", "complete", "failed"]; + for (const [locale, catalog] of Object.entries(catalogs)) { + const copy = catalog.settings.storage; + for (const field of fields) { + assert.equal(typeof copy[field], "string", `${locale}: ${field}`); + assert.ok(copy[field].trim(), `${locale}: ${field} must not be blank`); + } + assert.deepEqual(Object.keys(copy.stages).sort(), [...stages].sort(), locale); + for (const stage of stages) assert.ok(copy.stages[stage].trim(), `${locale}: ${stage}`); + if (locale !== "en") { + assert.notEqual(copy.progressTitle, en.settings.storage.progressTitle, locale); + assert.notEqual(copy.failedHint, en.settings.storage.failedHint, locale); + assert.notEqual(copy.unavailableHint, en.settings.storage.unavailableHint, locale); + } + } +}); + test("every shipped catalog matches English keys and interpolation variables", () => { for (const [id, catalog] of Object.entries(catalogs)) { const flat = flattenCatalog(catalog); diff --git a/packages/shared/src/changelog-de.ts b/packages/shared/src/changelog-de.ts index cb50ae9540..9955a8ae5f 100644 --- a/packages/shared/src/changelog-de.ts +++ b/packages/shared/src/changelog-de.ts @@ -5,6 +5,7 @@ export const deEntries: ChangelogEntry[] = [ "version": "0.16.0", "date": "2026-10-02", "highlights": [ + "Wählen Sie in den Einstellungen einen eigenen Datenspeicherort, verfolgen Sie die Migration und leeren Sie neu erzeugbare Caches sicher.", "Live Voice steht jetzt allen zur Verfügung, und Anrufe starten im aktuellen Verlauf.", "Neue Checkliste pro Verlauf, die ihren verbindlichen Stand auch nach einem Neustart des lokalen Dienstes behält.", "Einmalige Hinweise erscheinen jetzt im gemeinsamen Toast-Stapel statt in blockierenden Dialogen.", diff --git a/packages/shared/src/changelog-en.ts b/packages/shared/src/changelog-en.ts index 39b453322f..924bfe5c34 100644 --- a/packages/shared/src/changelog-en.ts +++ b/packages/shared/src/changelog-en.ts @@ -5,6 +5,7 @@ export const enEntries: ChangelogEntry[] = [ version: "0.16.0", date: "2026-10-02", highlights: [ + "Choose a custom data location, follow migration progress, and safely reclaim regenerable caches in Settings.", "Live Voice is now available to everyone and calls start in the current session.", "Add a session checklist that keeps its authoritative state after the local service restarts.", "Show one-off notices in the shared toast stack instead of blocking dialogs.", diff --git a/packages/shared/src/changelog-es.ts b/packages/shared/src/changelog-es.ts index e9518a1f20..e793efedd1 100644 --- a/packages/shared/src/changelog-es.ts +++ b/packages/shared/src/changelog-es.ts @@ -5,6 +5,7 @@ export const esEntries: ChangelogEntry[] = [ "version": "0.16.0", "date": "2026-10-02", "highlights": [ + "Elige una ubicación personalizada de datos, sigue el progreso de migración y limpia de forma segura la caché regenerable desde Ajustes.", "Live Voice ya está disponible para todos y las llamadas comienzan en la conversación actual.", "Nueva lista de tareas por conversación que conserva su estado autorizado aunque se reinicie el servicio local.", "Los avisos puntuales ahora aparecen en la pila de toasts compartida en lugar de diálogos bloqueantes.", diff --git a/packages/shared/src/changelog-fr.ts b/packages/shared/src/changelog-fr.ts index 9a38fb6b9b..295377d7f7 100644 --- a/packages/shared/src/changelog-fr.ts +++ b/packages/shared/src/changelog-fr.ts @@ -5,6 +5,7 @@ export const frEntries: ChangelogEntry[] = [ "version": "0.16.0", "date": "2026-10-02", "highlights": [ + "Choisissez un emplacement personnalisé, suivez la migration et nettoyez les caches régénérables en toute sécurité depuis les paramètres.", "Live Voice est désormais accessible à tous et les appels démarrent dans la conversation en cours.", "Nouvelle liste de tâches par conversation, qui conserve son état de référence même après un redémarrage du service local.", "Les notifications ponctuelles s’affichent maintenant dans la pile de toasts commune plutôt que dans des boîtes de dialogue bloquantes.", diff --git a/packages/shared/src/changelog-ko.ts b/packages/shared/src/changelog-ko.ts index ba1a2e491b..d2583beecd 100644 --- a/packages/shared/src/changelog-ko.ts +++ b/packages/shared/src/changelog-ko.ts @@ -5,6 +5,7 @@ export const koEntries: ChangelogEntry[] = [ "version": "0.16.0", "date": "2026-10-02", "highlights": [ + "설정에서 데이터 저장 위치를 지정하고 마이그레이션 진행률을 확인하며 다시 생성할 수 있는 캐시를 안전하게 정리하세요.", "라이브 보이스를 모든 사용자가 사용할 수 있으며 통화는 현재 세션에서 시작됩니다.", "세션별 체크리스트를 추가하고 로컬 서비스를 다시 시작해도 확정된 상태를 유지합니다.", "일회성 알림이 차단형 대화 상자 대신 공용 토스트 스택에 표시됩니다.", diff --git a/packages/shared/src/changelog-pt-BR.ts b/packages/shared/src/changelog-pt-BR.ts index 273e2b9edc..eb0868d283 100644 --- a/packages/shared/src/changelog-pt-BR.ts +++ b/packages/shared/src/changelog-pt-BR.ts @@ -5,6 +5,7 @@ export const ptBREntries: ChangelogEntry[] = [ "version": "0.16.0", "date": "2026-10-02", "highlights": [ + "Escolha um local personalizado para os dados, acompanhe a migração e limpe com segurança os caches regeneráveis nas Configurações.", "O Live Voice agora está disponível para todos e as chamadas começam na sessão atual.", "Nova lista de tarefas por sessão, que mantém o estado oficial mesmo após reiniciar o serviço local.", "Avisos pontuais agora aparecem na pilha de toasts compartilhada em vez de diálogos bloqueantes.", diff --git a/packages/shared/src/changelog-tr.ts b/packages/shared/src/changelog-tr.ts index 6a3300c737..e3e309c05e 100644 --- a/packages/shared/src/changelog-tr.ts +++ b/packages/shared/src/changelog-tr.ts @@ -5,6 +5,7 @@ export const trEntries: ChangelogEntry[] = [ "version": "0.16.0", "date": "2026-10-02", "highlights": [ + "Ayarlar üzerinden özel veri konumu seçin, taşıma ilerlemesini izleyin ve yeniden oluşturulabilir önbellekleri güvenle temizleyin.", "Canlı Ses artık herkesin kullanımına açık ve görüşmeler geçerli oturumda başlıyor.", "Yerel hizmet yeniden başlatılsa bile bağlayıcı durumunu koruyan oturum bazlı kontrol listesi eklendi.", "Tek seferlik bildirimler artık engelleyici iletişim kutuları yerine ortak toast yığınında gösteriliyor.", diff --git a/packages/shared/src/changelog-zh-CN.ts b/packages/shared/src/changelog-zh-CN.ts index 03850205ec..9ef4507779 100644 --- a/packages/shared/src/changelog-zh-CN.ts +++ b/packages/shared/src/changelog-zh-CN.ts @@ -5,6 +5,7 @@ export const zhCNEntries: ChangelogEntry[] = [ version: "0.16.0", date: "2026-10-02", highlights: [ + "设置中可自定义数据保存目录、查看迁移进度,并安全清理可重新生成的缓存。", "实时语音现已面向所有用户开放,通话默认从当前会话开始。", "新增会话级待办清单,本地服务重启后仍会保留权威状态。", "一次性通知改用统一浮层提示,不再弹出阻塞式对话框。", diff --git a/packages/shared/src/changelog-zh-TW.ts b/packages/shared/src/changelog-zh-TW.ts index 2fa8d87e5b..1026ee88eb 100644 --- a/packages/shared/src/changelog-zh-TW.ts +++ b/packages/shared/src/changelog-zh-TW.ts @@ -5,6 +5,7 @@ export const zhTWEntries: ChangelogEntry[] = [ version: "0.16.0", date: "2026-10-02", highlights: [ + "設定中可自訂資料儲存目錄、查看遷移進度,並安全清理可重新產生的快取。", "即時語音現已開放給所有使用者,通話預設從目前工作階段開始。", "新增工作階段待辦清單,本機服務重新啟動後仍會保留權威狀態。", "一次性通知改用統一的浮層提示,不再彈出阻擋式對話框。", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index aaf800f89e..d4406e7e72 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -73,3 +73,4 @@ export * from "./header-value.js"; export * from "./session-todos.js"; export * from "./tool-call-lineage.js"; export * from "./event-usage.js"; +export * from "./storage.js"; diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index ca1e51d751..50dc9bdc6a 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -50,6 +50,11 @@ export type WindowControlAction = (typeof WINDOW_CONTROL_ACTIONS)[number]; export const IPC = { invoke: { + storageGet: "pi-desktop/storage/get", + storageChoose: "pi-desktop/storage/choose", + storageMigrate: "pi-desktop/storage/migrate", + storageClearCache: "pi-desktop/storage/clearCache", + storageRemoveBackup: "pi-desktop/storage/removeBackup", appGetVersion: "pi-desktop/app/getVersion", appOpenFeedback: "pi-desktop/app/openFeedback", appHealth: "pi-desktop/app/health", diff --git a/packages/shared/src/storage.ts b/packages/shared/src/storage.ts new file mode 100644 index 0000000000..f6ee08642b --- /dev/null +++ b/packages/shared/src/storage.ts @@ -0,0 +1,18 @@ +/** Desktop-owned bootstrap storage preferences; never synchronized between devices. */ +export type StorageInfo = { + dataPath: string; + browserPath: string; + managed: boolean; + cacheBytes: number; + pendingPath: string | null; + lastError: string | null; + backupPaths: string[]; +}; + +export type StorageProgress = { + stage: "scanning" | "copying" | "verifying" | "relocating" | "cleaning" | "complete" | "failed"; + completedBytes: number; + totalBytes: number; + completedFiles: number; + totalFiles: number; +}; diff --git a/scripts/e2e-storage-bootstrap.mjs b/scripts/e2e-storage-bootstrap.mjs new file mode 100644 index 0000000000..3f083e8a16 --- /dev/null +++ b/scripts/e2e-storage-bootstrap.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** Real Chromium persistent state survives the production cold storage bootstrap. */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { repositoryRoot, resolveElectronBinary } from "./e2e/boot.mjs"; + +const root = repositoryRoot(); +const { build } = createRequire(join(root, "packages/agent-runtime/package.json"))("esbuild"); +const scratch = await realpath(await mkdtemp(join(tmpdir(), "pi-storage-bootstrap-"))); +const anchor = join(scratch, "old-browser"); +const data = join(scratch, "old-data"); +const target = join(scratch, "destination"); +const pointer = join(anchor, "storage-location.json"); +try { + await Promise.all([mkdir(anchor), mkdir(data), mkdir(target)]); + await writeFile(join(scratch, "index.html"), 'Isolated storage test'); + await mkdir(join(data, "sessions")); + await mkdir(join(data, "attachments")); + await writeFile(join(data, "attachments/test.png"), "fixture attachment"); + await writeFile(join(data, "sessions/chat.jsonl"), JSON.stringify({ type: "message", blocks: [{ type: "attachment", ref: join(data, "attachments/test.png") }] }) + "\n"); + await mkdir(join(data, "cache")); + await writeFile(join(data, "cache/test.bin"), "disposable"); + const main = join(scratch, "main.mjs"); + await build({ stdin: { contents: ` +import { app, BrowserWindow, session } from 'electron'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { prepareStorage } from './apps/desktop/electron/main/storage/bootstrap.ts'; +const scratch = process.env.STORAGE_E2E_ROOT; +const anchor = join(scratch, 'old-browser'); +const data = join(scratch, 'old-data'); +app.setPath('userData', anchor); +let maintenance; +app.on('browser-window-created', (_event, window) => { maintenance = window; }); +const originalExit = app.exit.bind(app); +const result = {}; +app.relaunch = (options) => { result.relaunched = true; result.relaunchOptions = options; }; +app.exit = (code = 0) => { + void (async () => { + if (maintenance && !maintenance.isDestroyed()) { + result.window = { title: maintenance.getTitle(), sandbox: maintenance.webContents.getLastWebPreferences().sandbox, + persistent: maintenance.webContents.session.isPersistent(), + view: await maintenance.webContents.executeJavaScript('({heading:document.querySelector("h1")?.textContent, stage:document.getElementById("stage")?.textContent, bytes:document.getElementById("detail")?.textContent, progress:document.querySelector("progress")?.value})') }; + } + result.pointer = readFileSync(join(anchor, 'storage-location.json'), 'utf8'); + writeFileSync(join(scratch, 'result.json'), JSON.stringify(result)); + originalExit(code); + })().catch((error) => { console.error(error); originalExit(1); }); +}; +async function openState(partition, value) { + const window = new BrowserWindow({show:false,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false,...(partition ? {partition} : {})}}); + await window.loadFile(join(scratch,'index.html')); + const state = await window.webContents.executeJavaScript(value + ? 'localStorage.setItem("storage-e2e",'+JSON.stringify(value)+');localStorage.getItem("storage-e2e")' + : 'localStorage.getItem("storage-e2e")'); + const targetSession = partition ? session.fromPartition(partition) : session.defaultSession; + targetSession.flushStorageData(); + window.destroy(); + return state; +} +void (async () => { +if (process.env.STORAGE_E2E_MODE === 'seed') { + await app.whenReady(); + await openState(null, 'main-local-state'); + await openState('persist:storage-e2e-plugin', 'plugin-local-state'); + originalExit(0); +} else { + const storage = await prepareStorage(data,false); + await app.whenReady(); + result.roots = storage.preferences.roots; + result.sessionData = app.getPath('sessionData'); + if (process.env.STORAGE_E2E_MODE === 'read') { + result.pluginData = session.fromPartition('persist:storage-e2e-plugin').getStoragePath(); + } + writeFileSync(join(scratch,'result.json'),JSON.stringify(result)); + originalExit(0); +} +})().catch((error) => { console.error(error); originalExit(1); }); +`, resolveDir: root, sourcefile: "storage-bootstrap-e2e.mjs", loader: "js" }, + outfile: main, bundle: true, platform: "node", format: "esm", target: "node24", external: ["electron"], + alias: { "@pi-desktop/i18n": join(root, "packages/i18n/src/index.ts") }, + plugins: [{ name: "host-binary-location", setup(builder) { + builder.onResolve({ filter: /host-process$/ }, () => ({ path: "host-binary", namespace: "host-binary" })); + builder.onLoad({ filter: /.*/, namespace: "host-binary" }, () => ({ contents: "export function resolveHostBinary() { return process.env.PI_DESKTOP_HOST_BIN; }" })); + } }], + }); + const hostBinary = process.env.PI_DESKTOP_HOST_BIN ?? join(root, "target/debug/pi-desktop-host-core"); + async function launch(mode) { + const resultPath = join(scratch, "result.json"); + await rm(resultPath, { force: true }); + const env = { ...process.env, STORAGE_E2E_ROOT: scratch, STORAGE_E2E_MODE: mode, PI_DESKTOP_HOST_BIN: hostBinary }; + delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(resolveElectronBinary(root).electronBinary, [main], { env, stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => { output += chunk; }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 30_000); + let code; + try { code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }); } + finally { clearTimeout(timeout); } + assert.equal(code, 0, output); + if (mode === "seed") return null; + try { return JSON.parse(await readFile(resultPath, "utf8")); } + catch (error) { throw new Error(`Electron ${mode} produced no result (exit ${code}): ${output}`, { cause: error }); } + } + await launch("seed"); + const sourceTranscript = await readFile(join(data, "sessions/chat.jsonl"), "utf8"); + await writeFile(pointer, JSON.stringify({ version: 1, roots: { data, browser: anchor }, backups: [], pending: { id: randomUUID(), kind: "migrate", target, language: "zh-CN" } })); + const migrated = await launch("maintenance"); + const preferences = JSON.parse(migrated.pointer); + assert.equal(preferences.roots.data, join(target, "data")); + assert.equal(preferences.roots.browser, join(target, "browser")); + assert.equal(migrated.relaunched, true); + assert.equal(migrated.window.sandbox, true); + assert.equal(migrated.window.persistent, false); + assert.match(migrated.window.view.heading, /迁移|存储|维护/); + assert.match(migrated.window.view.stage, /完成/); + assert.equal(await readFile(join(data, "sessions/chat.jsonl"), "utf8"), sourceTranscript); + assert.match(await readFile(join(target, "data/sessions/chat.jsonl"), "utf8"), /destination\/data\/attachments/); + await readFile(join(scratch, "index.html")); + const restarted = await launch("read"); + assert.equal(restarted.sessionData, join(target, "browser"), JSON.stringify(restarted)); + assert.equal(restarted.pluginData, join(target, "browser", "Partitions", "storage-e2e-plugin")); + for (const name of await (await import("node:fs/promises")).readdir(join(anchor, "Local Storage", "leveldb"))) { + assert.deepEqual(await readFile(join(target, "browser", "Local Storage", "leveldb", name)), await readFile(join(anchor, "Local Storage", "leveldb", name))); + } + preferences.pending = { id: randomUUID(), kind: "cache", language: "en" }; + await writeFile(pointer, JSON.stringify(preferences)); + await launch("maintenance"); + await assert.rejects(readFile(join(target, "data/cache/test.bin")), { code: "ENOENT" }); + assert.equal((await launch("read")).pluginData, join(target, "browser", "Partitions", "storage-e2e-plugin")); + const next = JSON.parse(await readFile(pointer, "utf8")); + next.pending = { id: randomUUID(), kind: "backup", language: "en" }; + await writeFile(pointer, JSON.stringify(next)); + await launch("maintenance"); + await assert.rejects(readFile(join(data, "sessions/chat.jsonl")), { code: "ENOENT" }); + assert.deepEqual(JSON.parse(await readFile(pointer, "utf8")).backups, []); + assert.equal((await launch("read")).sessionData, join(target, "browser")); + console.log("STORAGE_BOOTSTRAP " + JSON.stringify({ migration: true, progress: true, chromiumState: true, pluginState: true, safeCache: true, backupCleanup: true })); +} finally { await rm(scratch, { recursive: true, force: true }); } diff --git a/scripts/e2e-storage-migration.mjs b/scripts/e2e-storage-migration.mjs new file mode 100644 index 0000000000..b33c43b47e --- /dev/null +++ b/scripts/e2e-storage-migration.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** Real host RPC → cold Node copy → offline Rust relocation → reopened host. + * Uses only temporary profiles, local plugin fixtures, and no inference/network. + * Build host-core first and set PI_DESKTOP_HOST_BIN when reusing a shared target. + */ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { Host, resolveHostBinary } from "./e2e/host.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const binary = resolveHostBinary(); +const { build } = createRequire(join(root, "packages/agent-runtime/package.json"))("esbuild"); +const scratch = await realpath(await mkdtemp(join(tmpdir(), "pi-storage-migration-e2e-"))); +const source = { data: join(scratch, "old-data"), browser: join(scratch, "old-browser") }; +const target = join(scratch, "destination"); +const pluginSource = join(scratch, "plugin-source"); +const sourceHost = new Host(binary, source.data); +let destinationHost; +const storageBundle = join(scratch, "storage.mjs"); + +async function put(path, content, mode = 0o600) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, { mode }); +} +async function fingerprint(directory) { + const entries = []; + async function walk(path, suffix = "") { + const info = await lstat(path); + if (info.isSymbolicLink()) entries.push([suffix, "link", await readlink(path)]); + else if (info.isDirectory()) for (const name of (await readdir(path)).sort()) await walk(join(path, name), join(suffix, name)); + else entries.push([suffix, createHash("sha256").update(await readFile(path)).digest("hex"), info.mode & 0o777]); + } + await walk(directory); + return entries; +} +async function closeGracefully(host) { + host.child.stdin.end(); + const result = await Promise.race([ + host.exitPromise, + new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error("host EOF timeout")), 10_000); timer.unref(); }), + ]); + assert.equal(result.code, 0, host.stderr); + await host.stop(); +} + +try { + await Promise.all([mkdir(source.browser), mkdir(target), mkdir(pluginSource)]); + await build({ entryPoints: [join(root, "apps/desktop/electron/main/storage/files.ts")], + outfile: storageBundle, bundle: true, platform: "node", format: "esm" }); + const storage = await import(pathToFileURL(storageBundle).href); + await sourceHost.start(); + const { session } = await sourceHost.call("session.create", { title: "Migration survives restart" }); + const { path: sessionScratch } = await sourceHost.call("session.getScratchPath", { sessionId: session.id }); + const pasted = join(sessionScratch, "pasted", "input.txt"); + await put(pasted, "preserved user document"); + const insideProject = join(source.data, "projects", "local-project"); + const externalProject = join(scratch, "external-project"); + await Promise.all([mkdir(insideProject, { recursive: true }), mkdir(externalProject)]); + await sourceHost.call("workspace.set", { path: insideProject }); + await sourceHost.call("project.memory.set", { path: insideProject, content: "Durable project memory" }); + const { session: projectSession } = await sourceHost.call("session.create", { title: "Project binding", projectPath: insideProject }); + await sourceHost.call("workspace.set", { path: externalProject }); + await sourceHost.call("session.appendMessage", { sessionId: session.id, message: { + id: randomUUID(), role: "user", content: `Historical path: ${pasted}`, createdAt: new Date().toISOString(), + attachments: [{ kind: "file", name: "input.txt", ref: pasted, mimeType: "text/plain" }], + } }); + await sourceHost.call("session.queuePush", { id: randomUUID(), sessionId: session.id, principal: "user", + inputHash: "fixture-queued-input", content: "Queued request", permissionMode: "ask", + attachments: [{ kind: "file", name: "input.txt", ref: pasted }] }); + await sourceHost.call("settings.set", { language: "zh-CN", theme: "light", enterToSend: false }); + await sourceHost.call("secrets.set", { secretRef: "secret:e2e-storage-fixture", value: "temporary-fixture-credential" }); + await put(join(pluginSource, "manifest.json"), JSON.stringify({ schemaVersion: 1, id: "storage-e2e", name: "Storage fixture", version: "1.0.0", main: "main.js", permissions: [] })); + await put(join(pluginSource, "main.js"), "module.exports = {};"); + await sourceHost.call("plugins.installFromPath", { path: pluginSource, enable: false }); + await closeGracefully(sourceHost); + await put(join(source.data, "plugins/data/storage-e2e/state.json"), '{"keep":"private plugin state"}'); + await put(join(source.data, "cache/disposable.bin"), "disposable host cache"); + await put(join(source.data, "attachments/blob"), "durable attachment bytes"); + await put(join(source.data, "logs/keep.log"), "historical diagnostic log"); + await put(join(source.browser, "Local Storage/leveldb/000003.log"), "persisted renderer preferences fixture"); + await put(join(source.browser, "Partitions/plugin-fixture/Cookies"), "persisted cookie fixture"); + await put(join(source.browser, "Cache/disposable"), "disposable browser cache"); + if (process.platform !== "win32") { + await symlink(pasted, join(sessionScratch, "absolute-link")); + await chmod(join(source.data, "secrets/.machine-key"), 0o600); + } + const beforeData = await fingerprint(source.data); + const beforeBrowser = await fingerprint(source.browser); + const stages = []; + let relocations = 0; + const next = await storage.migrateFiles({ source, target, anchor: source.browser, id: randomUUID(), + progress: (value) => { stages.push(value.stage); }, + relocate: async (oldRoot, newRoot) => { + relocations++; + assert(stages.includes("verifying"), "copy verification precedes Rust database relocation"); + await promisify(execFile)(binary, ["--relocate-data", oldRoot, newRoot], { timeout: 20_000, maxBuffer: 1024 * 1024 }); + }, + }); + assert.equal(relocations, 1); + for (const stage of ["scanning", "copying", "verifying", "relocating", "complete"]) assert(stages.includes(stage), stage); + assert.deepEqual(await fingerprint(source.data), beforeData, "old data profile stays byte-identical"); + assert.deepEqual(await fingerprint(source.browser), beforeBrowser, "old browser profile stays byte-identical"); + const movedPasted = join(next.data, "scratch", session.id, "pasted/input.txt"); + assert.equal(await readFile(movedPasted, "utf8"), "preserved user document"); + if (process.platform !== "win32") { + assert.equal(await readlink(join(next.data, "scratch", session.id, "absolute-link")), movedPasted); + assert.equal((await lstat(join(next.data, "secrets/.machine-key"))).mode & 0o777, 0o600); + } + destinationHost = new Host(binary, next.data); + await destinationHost.start(); + const { session: restored } = await destinationHost.call("session.get", { id: session.id }); + assert.equal(restored.messages[0].attachments[0].ref, movedPasted); + assert.equal(restored.messages[0].content, `Historical path: ${pasted}`); + const { entries } = await destinationHost.call("session.queueList", { sessionId: session.id }); + assert.equal(entries[0].attachments[0].ref, movedPasted); + const { session: rebound } = await destinationHost.call("session.get", { id: projectSession.id }); + assert.equal(rebound.projectPath, join(next.data, "projects/local-project")); + const { memory } = await destinationHost.call("project.memory.get", { path: join(next.data, "projects/local-project") }); + assert.equal(memory.content, "Durable project memory"); + const settings = await destinationHost.call("settings.get"); + assert.equal(settings.language, "zh-CN"); assert.equal(settings.theme, "light"); assert.equal(settings.enterToSend, false); + const { value: credential } = await destinationHost.call("secrets.getForRuntime", { secretRef: "secret:e2e-storage-fixture" }); + assert.equal(credential, "temporary-fixture-credential"); + const { plugins } = await destinationHost.call("plugins.list"); + assert.equal(plugins.find((plugin) => plugin.id === "storage-e2e").path, join(next.data, "plugins/installed/storage-e2e")); + await closeGracefully(destinationHost); + const critical = ["pi.sqlite", "sessions", "secrets", "scratch", "attachments", "plugins/data", "logs"]; + const preserved = await Promise.all(critical.map((name) => fingerprint(join(next.data, name)))); + await storage.clearCaches(next); + assert.deepEqual(await Promise.all(critical.map((name) => fingerprint(join(next.data, name)))), preserved); + await assert.rejects(readFile(join(next.data, "cache/disposable.bin")), { code: "ENOENT" }); + await assert.rejects(readFile(join(next.browser, "Cache/disposable")), { code: "ENOENT" }); + assert.equal(await readFile(join(next.browser, "Local Storage/leveldb/000003.log"), "utf8"), "persisted renderer preferences fixture"); + assert.equal(await readFile(join(next.browser, "Partitions/plugin-fixture/Cookies"), "utf8"), "persisted cookie fixture"); + console.log("PASS real storage migration: verified Node copy, offline Rust CLI, reopened session/project/plugin/settings/credential/queue, visible stages, source backups, cache preservation"); +} finally { + await sourceHost.stop(); + await destinationHost?.stop(); + await rm(scratch, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +} diff --git a/scripts/e2e-storage-settings.mjs b/scripts/e2e-storage-settings.mjs new file mode 100644 index 0000000000..125e2f69d3 --- /dev/null +++ b/scripts/e2e-storage-settings.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** Storage settings in isolated Electron, with the production component/store/CSS. */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, basename } from "node:path"; +import { repositoryRoot, resolveElectronBinary } from "./e2e/boot.mjs"; + +const root = repositoryRoot(); +const { build } = createRequire(join(root, "packages/agent-runtime/package.json"))("esbuild"); +const temp = await mkdtemp(join(tmpdir(), "pi-storage-settings-")); +try { + await build({ entryPoints: [join(root, "scripts/e2e/storage-settings.jsx")], + outfile: join(temp, "renderer.js"), bundle: true, platform: "browser", format: "esm", jsx: "automatic", + define: { "process.env.NODE_ENV": '"production"', "import.meta.env.DEV": "false" }, + plugins: [{ name: "fixture-url-assets", setup(builder) { + builder.onResolve({ filter: /\?url$/ }, (args) => ({ path: resolve(dirname(args.importer), args.path.slice(0, -4)), namespace: "fixture-url" })); + builder.onLoad({ filter: /.*/, namespace: "fixture-url" }, async ({ path }) => { + const name = basename(path); + await cp(path, join(temp, name)); + return { contents: `export default ${JSON.stringify(`./${name}`)}`, loader: "js" }; + }); + } }], + alias: { "@pi-desktop/shared": join(root, "packages/shared/src/index.ts"), + "@pi-desktop/shared/changelog-loader": join(root, "packages/shared/src/changelog-loader.ts"), + "@pi-desktop/i18n": join(root, "packages/i18n/src/index.ts"), + "@pi-desktop/i18n/locale-info": join(root, "packages/i18n/src/locale-info.ts"), + ...Object.fromEntries(["en", "zh-CN", "zh-TW", "tr", "de", "es", "fr", "ko", "pt-BR"].map((locale) => + [`@pi-desktop/i18n/locales/${locale}`, join(root, `packages/i18n/src/locales/${locale}/index.ts`)])), + "@pi-desktop/voice-runtime/live": join(root, "packages/voice-runtime/src/live/index.ts"), + react: join(root, "apps/desktop/node_modules/react"), + "react-dom": join(root, "apps/desktop/node_modules/react-dom"), + i18next: join(root, "apps/desktop/node_modules/i18next"), + "react-i18next": join(root, "apps/desktop/node_modules/react-i18next") }, + nodePaths: [join(root, "apps/desktop/node_modules")], + }); + const renderer = join(root, "apps/desktop/out/renderer"); + const html = await readFile(join(renderer, "index.html"), "utf8"); + const css = [...html.matchAll(/href="([^" ]+\.css)"/g)].map((match) => match[1]); + assert(css.length, "Run pnpm build:js before this test"); + await cp(join(renderer, "assets"), join(temp, "assets"), { recursive: true }); + await writeFile(join(temp, "index.html"), `${css.map((path) => ``).join("")}
`); + await writeFile(join(temp, "main.cjs"), ` +const { app, BrowserWindow } = require("electron"); +const path = require("node:path"); +app.setPath("userData", path.join(__dirname, "profile")); +app.whenReady().then(async () => { + const win = new BrowserWindow({ show: false, width: 1000, height: 720, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }); + win.webContents.on("console-message", (event) => console.error(event.message)); + try { + await win.loadFile(path.join(__dirname, "index.html")); + const result = await win.webContents.executeJavaScript("window.storageSettingsProbe()"); + console.log("STORAGE_SETTINGS " + JSON.stringify(result)); + app.exit(0); + } catch (error) { console.error(error); app.exit(1); } +}); +`); + const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(resolveElectronBinary(root).electronBinary, [join(temp, "main.cjs")], { env, stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => { output += chunk; }); + const timer = setTimeout(() => child.kill("SIGKILL"), 30_000); + let code; + try { code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }); } + finally { clearTimeout(timer); } + assert.equal(code, 0, output); + const result = output.split(/\r?\n/).find((line) => line.startsWith("STORAGE_SETTINGS ")); + assert(result, output); + console.log(result); +} finally { await rm(temp, { recursive: true, force: true }); } diff --git a/scripts/e2e/storage-settings.jsx b/scripts/e2e/storage-settings.jsx new file mode 100644 index 0000000000..95045f724e --- /dev/null +++ b/scripts/e2e/storage-settings.jsx @@ -0,0 +1,156 @@ +// Production SettingsPage and API; fixtures stop at the isolated preload boundary. +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import { catalogs } from "@pi-desktop/i18n"; +import { IPC } from "@pi-desktop/shared"; +import { SettingsPage } from "../../apps/desktop/src/features/settings/SettingsPage"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; + +const baseline = { + dataPath: "/old/PI-Desktop", browserPath: "/old/chromium", managed: true, + cacheBytes: 2 * 1024 * 1024, pendingPath: null, lastError: null, + backupPaths: ["/previous/PI-Desktop"], +}; +let info = { ...baseline }; +let chosen = "/new/PI-Desktop"; +let readError = false; +let operationError = false; +let operationGate = null; +const calls = []; +window.piDesktop = { + platform: "darwin", locale: "en-US", + on: () => () => {}, + async invoke(channel, input) { + let data; + switch (channel) { + case IPC.invoke.storageGet: + if (readError) return { ok: false, error: { code: "TEST", message: "storage unavailable" } }; + data = info; + break; + case IPC.invoke.storageChoose: data = chosen; break; + case IPC.invoke.storageMigrate: + case IPC.invoke.storageClearCache: + case IPC.invoke.storageRemoveBackup: + calls.push({ channel, input }); + if (operationGate) await operationGate; + if (operationError) return { ok: false, error: { code: "TEST", message: "target not writable" } }; + break; + case IPC.invoke.pluginScenicThemesDestinations: data = []; break; + case IPC.invoke.systemFontsList: data = []; break; + default: data = null; + } + return { ok: true, data }; + }, +}; +await i18n.use(initReactI18next).init({ lng: "en", fallbackLng: "en", interpolation: { escapeValue: false }, resources: Object.fromEntries(Object.entries(catalogs).map(([language, catalog]) => [language, { translation: catalog }])) }); +useAppStore.setState({ settingsTab: "general", settings: { language: "en", theme: "light", defaultMode: "agent", enterToSend: true }, version: null }); +const root = createRoot(document.getElementById("root")); +let revision = 0; +const assert = (value, message) => { if (!value) throw new Error(message); }; +const text = (key) => i18n.t(key); +const scope = () => document.querySelector(".settings-storage"); +const button = (key) => [...scope().querySelectorAll("button")].find((node) => node.textContent.trim() === text(key)); +const click = (key) => { + const target = button(key); + assert(target && !target.disabled, `Expected enabled ${key}`); + flushSync(() => target.click()); +}; +const settled = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); +async function mount(overrides = {}) { + info = { ...baseline, ...overrides }; + flushSync(() => root.render()); + await settled(); +} +window.storageSettingsProbe = async () => { + const checks = []; + await mount(); + assert(scope().textContent.includes(baseline.dataPath), "Current data path must be visible"); + assert(scope().textContent.includes("2 MiB"), "Reclaimable size must be visible"); + chosen = null; + click("settings.storage.choose"); + await settled(); + assert(!scope().querySelector(".settings-storage-confirmation"), "Canceling native picker must not begin migration"); + chosen = "/new/PI-Desktop"; + click("settings.storage.choose"); + await settled(); + assert(scope().querySelector(".settings-storage-confirmation").textContent.includes(chosen), "Chosen target must be shown before confirmation"); + assert(scope().querySelector(".settings-storage-confirmation").textContent.includes(baseline.browserPath), "A separate Chromium source must be shown in migration scope"); + assert(document.activeElement === scope().querySelector(".settings-storage-confirmation h4"), "Migration confirmation must receive keyboard focus"); + assert(scope().textContent.includes("Project source folders stay where they are"), "Confirmation must explain migration scope"); + click("common.cancel"); + assert(calls.length === 0, "Cancel must not request migration"); + checks.push("choose and cancel"); + + click("settings.storage.choose"); + await settled(); + operationError = true; + click("settings.storage.migrateRestart"); + await settled(); + assert(calls.length === 1 && calls[0].channel === IPC.invoke.storageMigrate, "Confirm must reach typed migration IPC"); + assert(calls[0].input.path === chosen && calls[0].input.language === "en", "Migration must carry target and resolved locale"); + assert(scope().querySelector('[role="alert"]').textContent.includes("target not writable"), "Failure must be visible"); + assert(scope().textContent.includes(baseline.dataPath), "Failure must retain active path"); + operationError = false; + let release; + operationGate = new Promise((resolve) => { release = resolve; }); + click("settings.storage.migrateRestart"); + await settled(); + assert(button("settings.storage.restarting").disabled && button("settings.storage.choose").disabled, "Restart preparation must prevent duplicate operations"); + release(); + operationGate = null; + await settled(); + assert(calls.length === 2 && button("settings.storage.restarting").disabled, "A failed operation can be retried and then stays locked until restart"); + checks.push("migration failure and retry"); + + await mount(); + click("settings.storage.clearCache"); + assert(scope().querySelector(".settings-storage-confirmation").textContent.includes("plugin data and models are preserved"), "Cache confirmation must state protected data"); + click("common.cancel"); + assert(calls.length === 2, "Canceling cache cleanup must do nothing"); + click("settings.storage.clearCache"); + click("settings.storage.cacheRestart"); + await settled(); + assert(calls.at(-1).channel === IPC.invoke.storageClearCache && calls.at(-1).input.language === "en", "Confirmed cleanup must reach cache IPC"); + assert(button("settings.storage.choose").disabled, "Accepted cache cleanup must stay locked until restart"); + await mount(); + click("settings.storage.removeBackup"); + assert(scope().querySelector(".settings-storage-confirmation").textContent.includes("after verifying"), "Backup removal must advise verification"); + click("common.cancel"); + assert(calls.length === 3, "Canceling backup removal must do nothing"); + click("settings.storage.removeBackup"); + click("settings.storage.backupRestart"); + await settled(); + assert(calls.at(-1).channel === IPC.invoke.storageRemoveBackup, "Confirmed backup deletion must reach backup IPC"); + assert(button("settings.storage.choose").disabled, "Accepted backup removal must stay locked until restart"); + checks.push("cache and backup confirmations"); + + await mount({ managed: false }); + assert(button("settings.storage.choose").disabled && button("settings.storage.clearCache").disabled && button("settings.storage.removeBackup").disabled, "Environment-owned locations must prohibit all mutations"); + assert(scope().textContent.includes("environment variable"), "Environment ownership must be explained"); + await mount({ pendingPath: "/pending", lastError: "Copy failed" }); + assert(button("settings.storage.choose").disabled && scope().textContent.includes("/pending"), "Pending migration must be visible and prevent duplicate operation"); + assert(scope().querySelector('[role="alert"]').textContent.includes("active location was not changed"), "Failed offline operation must explain recovery"); + await mount({ cacheBytes: 0, backupPaths: [] }); + assert(button("settings.storage.clearCache").disabled && !button("settings.storage.removeBackup"), "Empty cache and no backups must prevent meaningless cleanup"); + checks.push("ownership pending and empty states"); + + readError = true; + await mount(); + assert(scope().querySelector('[role="alert"]').textContent.includes("storage unavailable"), "Read failure must remain observable"); + readError = false; + click("settings.storage.retry"); + await settled(); + assert(button("settings.storage.choose"), "Retry must restore the loaded storage controls"); + await i18n.changeLanguage("zh-CN"); + await settled(); + click("settings.storage.clearCache"); + click("settings.storage.cacheRestart"); + await settled(); + assert(calls.at(-1).input.language === "zh-CN", "Restart workflow must use current UI locale"); + assert(scope().textContent.includes("数据保存路径"), "Storage labels must translate with the UI"); + checks.push("read recovery and localization"); + root.unmount(); + return { ok: true, checks }; +};