Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/electron/main/entry.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
2 changes: 1 addition & 1 deletion apps/desktop/electron/main/host-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
57 changes: 11 additions & 46 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import {
} from "./network-proxy";
import { installInsecureEndpointNotice } from "./network-notice";
import {
APP_ID,
APP_NAME,
APP_VERSION,
IPC,
IPC_WHITELIST,
Expand All @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -997,7 +962,7 @@ app.on("browser-window-created", (_event, window) => {
});
});
});
app.once("ready", () => {
void app.whenReady().then(() => {
installLiveMicrophonePermissionHandlers({
targetSession: session.defaultSession,
getMainWindow,
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/electron/main/installation.ts
Original file line number Diff line number Diff line change
@@ -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");
3 changes: 3 additions & 0 deletions apps/desktop/electron/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -60,6 +61,7 @@ export type RegisterIpcDependencies = {
disabledBuiltinSubagents: () => Promise<string[]>;
liveCallService?: LiveCallService;
liveVoiceWidget?: LiveVoiceWidget;
restartForStorage: () => void;
mcpOAuth?: McpOAuthManager;
[name: string]: any;
};
Expand Down Expand Up @@ -237,6 +239,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) {
safeOpenExternal,
updater,
});
registerStorageIpc({ registrar, getMainWindow, restart: dependencies.restartForStorage });
registerNotificationIpc({
registrar,
getHost,
Expand Down
105 changes: 105 additions & 0 deletions apps/desktop/electron/main/storage/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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<StorageBootstrap> {
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) });
Comment on lines +38 to +39
app.exit(1);
return new Promise<StorageBootstrap>(() => {});
}
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(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><style>body{font:15px system-ui;margin:36px;background:#17191d;color:#f0f1f3}h1{font-size:21px}p{line-height:1.6;color:#b8bdc7}progress{width:100%;height:12px;accent-color:#a3bffa}#detail{font-variant-numeric:tabular-nums}</style></head><body><h1>${escapeHtml(copy.progressTitle)}</h1><p>${escapeHtml(copy.progressHint)}</p><p id="stage" role="status" aria-live="polite"></p><progress aria-label="${escapeHtml(copy.progressTitle)}"></progress><p id="detail"></p></body></html>`)}`);
let lastPaint = 0;
let paint: Promise<unknown> = 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),
}] });
Comment on lines +81 to +85
} 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<StorageBootstrap>(() => {});
}
Loading
Loading