Files
paseo/packages/desktop/src/main.ts
T
paseo-bot[bot]andmistop 9551b2cc11 Open loaded Linux desktop windows when hidden rendering stalls (#6726)
* fix(desktop): show the window when ready-to-show never fires on Linux

Reveal hidden Linux windows that do not receive an initial compositor frame.

* fix(desktop): open loaded Linux windows without waiting for a frame

A hidden Linux window can finish loading without painting, so waiting only for ready-to-show leaves the application invisible. Reveal the loaded document and retain the frame-ready path on other platforms.

Co-authored-by: mistop <jhlim@mistop.org>

---------

Co-authored-by: mistop <jhlim@mistop.org>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 18:05:04 +02:00

1070 lines
36 KiB
TypeScript

process.emitWarning = (() => {}) as typeof process.emitWarning;
import log from "electron-log/main";
log.transports.console.level = "info";
log.initialize({ spyRendererConsole: true });
import { inheritLoginShellEnv } from "./login-shell-env.js";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import {
app,
autoUpdater as electronAutoUpdater,
BrowserWindow,
ClipboardItem,
clipboard,
Menu,
ipcMain,
nativeImage,
net,
protocol,
screen,
session,
shell,
webContents,
} from "electron";
import { registerDaemonManager } from "./daemon/daemon-manager.js";
import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js";
import { closeAllTransportSessions } from "./daemon/local-transport.js";
import {
applyDesktopWindowChromeMode,
registerWindowManager,
getMainWindowChromeOptions,
getWindowBackgroundColor,
resolveSystemWindowTheme,
resolveWindowBounds,
setupWindowResizeEvents,
setupWindowStatePersistence,
setupDefaultContextMenu,
setupDragDropPrevention,
buildStandardContextMenuItems,
} from "./window/window-manager.js";
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
import { showWindowWhenReady } from "./window/show-when-ready.js";
import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js";
import { registerDialogHandlers } from "./features/dialogs.js";
import {
registerNotificationHandlers,
ensureNotificationCenterRegistration,
} from "./features/notifications.js";
import { createExternalUrlOpener } from "./features/opener.js";
import { createBrowserCaptureService } from "./features/browser-capture.js";
import { registerEditorTargetHandlers } from "./features/editor-targets/ipc.js";
import { resolveAppIconPath } from "./features/stamped-icon.js";
import { setupApplicationMenu } from "./features/menu.js";
import {
BROWSER_NEW_TAB_REQUEST_EVENT,
decideBrowserWindowOpenRequest,
getPaseoBrowserIdForWebContents,
getPaseoBrowserWebContentsForHostWindow,
getPaseoBrowserWebviewRegistry,
listRegisteredPaseoBrowserIds,
isPaseoBrowserWebviewAttach,
preparePaseoBrowserWebContents,
PendingBrowserWindowOpenRequests,
registerBrowserWebviewNavigationGuards,
unregisterPaseoBrowserFromHost,
registerAttachedPaseoBrowser,
setWorkspaceActivePaseoBrowserId,
unregisterPaseoBrowserHost,
} from "./features/browser-webviews/index.js";
import {
clearPaseoBrowserProfile,
getLegacyPaseoBrowserProfileSession,
PASEO_BROWSER_PROFILE_PARTITION,
getPaseoBrowserProfileSession,
getPaseoBrowserProfileSessions,
listPaseoBrowserProfileGuests,
readLegacyPaseoBrowserIds,
} from "./features/browser-profile.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
import {
createDesktopWindowOwner,
type DesktopWindowOwner,
type OwnedDesktopWindow,
} from "./window/desktop-window-owner.js";
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
import {
isDesktopManagedDaemonRunningSync,
stopDesktopDaemonViaCli,
} from "./daemon/daemon-manager.js";
import {
createQuitLifecycle,
registerExternalQuitSignals,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
import { runDesktopStartup } from "./desktop-startup.js";
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
import { BrowserKeyboard } from "./features/browser-keyboard/index.js";
import { installAppUpdateOnQuit } from "./features/auto-updater.js";
import {
buildAgentDeepLinkRoute,
parseAgentDeepLink,
type AgentDeepLinkTarget,
} from "@getpaseo/protocol/agent-deep-link";
import { AgentNavigationInbox, parseAgentDeepLinkFromArgv } from "./agent-navigation.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
const PASEO_DEBUG = process.env.PASEO_DEBUG === "1";
const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1";
const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo";
const DESKTOP_WINDOW_CHROME_MODE = resolveDesktopWindowChromeMode({
platform: process.platform,
override: process.env.PASEO_DESKTOP_WINDOW_CONTROLS,
isPackaged: app.isPackaged,
});
const UPDATE_QUIT_DEADLINE_MS = 5_000;
const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests();
const agentNavigationInbox = new AgentNavigationInbox();
// A second-instance launch can arrive before the packaged protocol handler,
// IPC handlers, and first window exist. Wait for full bootstrap, not just
// app.whenReady(), before delivering navigation to the renderer.
let resolveBootstrapComplete: () => void;
const bootstrapComplete = new Promise<void>((resolve) => {
resolveBootstrapComplete = resolve;
});
let bootstrapIsComplete = false;
app.setName(APP_NAME);
log.info("[desktop] app startup", {
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
isPackaged: app.isPackaged,
});
interface AttachedBrowserInput {
browserId: string;
workspaceId: string;
webContentsId: number;
}
function readAttachedBrowserInput(input: unknown): AttachedBrowserInput | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return null;
}
const record = input as Record<string, unknown>;
if (typeof record.browserId !== "string" || record.browserId.trim().length === 0) {
return null;
}
if (typeof record.workspaceId !== "string" || record.workspaceId.trim().length === 0) {
return null;
}
if (
typeof record.webContentsId !== "number" ||
!Number.isInteger(record.webContentsId) ||
record.webContentsId <= 0
) {
return null;
}
return {
browserId: record.browserId.trim(),
workspaceId: record.workspaceId.trim(),
webContentsId: record.webContentsId,
};
}
function readActiveBrowserInput(
input: unknown,
): { workspaceId: string; browserId: string | null } | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return null;
}
const record = input as Record<string, unknown>;
if (typeof record.workspaceId !== "string" || record.workspaceId.trim().length === 0) {
return null;
}
const browserId = typeof record.browserId === "string" ? record.browserId.trim() : null;
return { workspaceId: record.workspaceId.trim(), browserId: browserId || null };
}
const browserKeyboard = new BrowserKeyboard(getPaseoBrowserWebviewRegistry());
browserKeyboard.registerIpc();
function showBrowserWebviewContextMenu(
win: BrowserWindow,
contents: Electron.WebContents,
params: Electron.ContextMenuParams,
): void {
const menu = Menu.buildFromTemplate([
...buildStandardContextMenuItems(contents, params),
...(app.isPackaged
? []
: [
{ type: "separator" as const },
{
label: "Inspect Element",
click: () => {
log.info("[browser-devtools] inspect-element.request", {
webContentsId: contents.id,
browserId: getPaseoBrowserIdForWebContents(contents),
x: params.x,
y: params.y,
isDevToolsOpened: contents.isDevToolsOpened(),
});
contents.openDevTools({ mode: "detach" });
contents.inspectElement(params.x, params.y);
log.info("[browser-devtools] inspect-element.done", {
webContentsId: contents.id,
isDevToolsOpened: contents.isDevToolsOpened(),
});
},
},
]),
]);
menu.popup({ window: win });
}
function getBrowserPopupWindowOptions(
mainWindow: BrowserWindow,
): Electron.BrowserWindowConstructorOptions {
return {
parent: mainWindow,
show: true,
autoHideMenuBar: true,
webPreferences: {
partition: PASEO_BROWSER_PROFILE_PARTITION,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
webviewTag: false,
allowRunningInsecureContent: false,
},
};
}
function installBrowserWindowOpenHandler(input: {
contents: Electron.WebContents;
sourceContents: Electron.WebContents;
mainWindow: BrowserWindow;
}): void {
const { contents, sourceContents, mainWindow } = input;
contents.setWindowOpenHandler(({ url, disposition, frameName, features, postBody }) => {
const decision = decideBrowserWindowOpenRequest({
url,
disposition,
frameName,
features,
hasPostBody: postBody !== undefined && postBody !== null,
});
if (decision.kind === "deny") {
return { action: "deny" };
}
if (decision.kind === "popup") {
return {
action: "allow",
overrideBrowserWindowOptions: getBrowserPopupWindowOptions(mainWindow),
};
}
const sourceBrowserId = getPaseoBrowserIdForWebContents(sourceContents);
if (sourceBrowserId) {
mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, {
sourceBrowserId,
url: decision.url,
});
} else {
pendingBrowserWindowOpenRequests.add(sourceContents.id, decision.url);
}
return { action: "deny" };
});
contents.on("did-create-window", (popupWindow) => {
const popupContents = popupWindow.webContents;
registerBrowserWebviewNavigationGuards(popupContents);
popupContents.on("context-menu", (_event, params) => {
showBrowserWebviewContextMenu(popupWindow, popupContents, params);
});
installBrowserWindowOpenHandler({
contents: popupContents,
sourceContents,
mainWindow,
});
});
}
// In dev mode, detect git worktrees and isolate each instance so multiple
// Electron windows can run side-by-side (separate userData = separate lock).
let devWorktreeName: string | null = null;
const forcedUserDataDir = process.env.PASEO_ELECTRON_USER_DATA_DIR?.trim();
if (forcedUserDataDir) {
app.setPath("userData", forcedUserDataDir);
log.info("[dev-user-data] forced userData dir:", forcedUserDataDir);
} else if (!app.isPackaged) {
try {
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf-8",
timeout: 3000,
windowsHide: true,
}).trim();
devWorktreeName = path.basename(topLevel);
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
const commonDir = path.resolve(
topLevel,
execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: topLevel,
encoding: "utf-8",
timeout: 3000,
windowsHide: true,
}).trim(),
);
const isWorktree = path.resolve(topLevel, ".git") !== commonDir;
if (isWorktree) {
app.setPath("userData", path.join(app.getPath("appData"), `Paseo-${devWorktreeName}`));
log.info("[worktree] isolated userData for worktree:", devWorktreeName);
} else {
devWorktreeName = null;
}
} catch {
devWorktreeName = null;
}
}
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
// Must run before app.whenReady().
const electronFlags = process.env.PASEO_ELECTRON_FLAGS?.trim();
if (electronFlags) {
for (const token of electronFlags.split(/\s+/)) {
const [key, ...rest] = token.replace(/^--/, "").split("=");
app.commandLine.appendSwitch(key, rest.join("=") || undefined);
}
log.info("[electron-flags]", electronFlags);
}
if (process.platform === "linux") {
// Keep the desktop/dock identity independent of the wrapped Electron filename.
app.setDesktopName("Paseo.desktop");
if (!app.commandLine.hasSwitch("class")) app.commandLine.appendSwitch("class", "Paseo");
log.info("[linux-sandbox]", {
enabled: !app.commandLine.hasSwitch("no-sandbox"),
reason: process.env.PASEO_DESKTOP_SANDBOX_REASON ?? "Chromium default",
});
}
let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
argv: process.argv,
isDefaultApp: process.defaultApp,
});
let pendingAgentNavigation = parseAgentDeepLinkFromArgv(process.argv);
// Each window pulls its own pending open-project path on mount, keyed by
// webContents id, so deep-linked windows (second-instance launches, the
// in-app "Open in new window" action) land on the right project without
// racing a global.
let desktopWindowOwner: DesktopWindowOwner<AgentDeepLinkTarget>;
if (PASEO_DEBUG) {
log.info("[open-project] argv:", process.argv);
log.info("[open-project] isDefaultApp:", process.defaultApp);
log.info("[open-project] pendingOpenProjectPath:", pendingOpenProjectPath);
}
// The renderer pulls the pending path on mount via IPC — this avoids
// a race where the push event arrives before React registers its listener.
ipcMain.handle("paseo:get-pending-open-project", (event) => {
const webContentsId = event.sender.id;
const result = desktopWindowOwner.takePendingProject(webContentsId);
log.info("[open-project] renderer requested pending path:", {
webContentsId,
pendingPath: result,
});
return result;
});
ipcMain.handle("paseo:agent-navigation:ready", (event) => {
return agentNavigationInbox.windowReady(event.sender.id);
});
ipcMain.handle("paseo:browser:register-attached", (event, rawInput: unknown) => {
const input = readAttachedBrowserInput(rawInput);
if (!input) {
throw new Error("Invalid attached browser registration");
}
const registered = registerAttachedPaseoBrowser({
...input,
sender: event.sender,
profileSession: getPaseoBrowserProfileSession(session),
findWebContents: (webContentsId) => webContents.fromId(webContentsId) ?? null,
});
if (!registered) {
throw new Error("Attached browser registration was rejected");
}
const guest = webContents.fromId(input.webContentsId);
if (!guest) {
throw new Error("Attached browser guest disappeared after registration");
}
browserKeyboard.attach({ contents: guest, hostContents: event.sender });
log.info("[browser-webview] registered", {
browserId: input.browserId,
webContentsId: input.webContentsId,
registeredBrowserIds: listRegisteredPaseoBrowserIds(),
});
for (const url of pendingBrowserWindowOpenRequests.take(input.webContentsId)) {
event.sender.send(BROWSER_NEW_TAB_REQUEST_EVENT, {
sourceBrowserId: input.browserId,
url,
});
}
});
ipcMain.handle("paseo:browser:unregister-workspace-browser", async (event, browserId: unknown) => {
if (typeof browserId === "string" && browserId.trim().length > 0) {
const normalizedBrowserId = browserId.trim();
const hasOtherHost = getPaseoBrowserWebviewRegistry().hasBrowserInOtherHostWindow(
event.sender.id,
normalizedBrowserId,
);
unregisterPaseoBrowserFromHost(event.sender.id, normalizedBrowserId);
// COMPAT(browserProfile): added in v0.1.108; remove after 2027-01-15.
const legacyProfile = hasOtherHost
? null
: getLegacyPaseoBrowserProfileSession(session, normalizedBrowserId);
if (legacyProfile) {
try {
await clearPaseoBrowserProfile({
profileSessions: [legacyProfile],
listGuests: () => [],
logReloadError: () => {},
});
} catch (error) {
log.warn("[browser-profile] failed to clear legacy tab profile", {
browserId: normalizedBrowserId,
error,
});
}
}
}
});
ipcMain.handle("paseo:browser:set-workspace-active-browser", (event, rawInput: unknown) => {
const input = readActiveBrowserInput(rawInput);
if (input) {
setWorkspaceActivePaseoBrowserId({ ...input, hostWebContentsId: event.sender.id });
}
});
ipcMain.handle("paseo:browser:focus", (event, browserId: unknown): boolean => {
if (typeof browserId !== "string" || browserId.trim().length === 0) {
return false;
}
const contents = getPaseoBrowserWebContentsForHostWindow(browserId, event.sender.id);
if (!contents) {
return false;
}
contents.focus();
return true;
});
ipcMain.handle("paseo:browser:open-devtools", (event, browserId: unknown) => {
if (typeof browserId !== "string" || browserId.trim().length === 0) {
const result = {
ok: false,
reason: "invalid-browser-id",
browserId,
registeredBrowserIds: listRegisteredPaseoBrowserIds(),
};
log.warn("[browser-devtools] open-devtools.invalid", result);
return result;
}
const contents = getPaseoBrowserWebContentsForHostWindow(browserId, event.sender.id);
if (!contents) {
const result = {
ok: false,
reason: "browser-webcontents-not-found",
browserId,
registeredBrowserIds: listRegisteredPaseoBrowserIds(),
};
log.warn("[browser-devtools] open-devtools.not-found", result);
return result;
}
log.info("[browser-devtools] open-devtools.request", {
browserId,
webContentsId: contents.id,
isDestroyed: contents.isDestroyed(),
isDevToolsOpened: contents.isDevToolsOpened(),
registeredBrowserIds: listRegisteredPaseoBrowserIds(),
});
contents.openDevTools({ mode: "detach" });
const result = {
ok: true,
reason: "opened",
browserId,
webContentsId: contents.id,
isDevToolsOpened: contents.isDevToolsOpened(),
};
log.info("[browser-devtools] open-devtools.done", result);
return result;
});
ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds: unknown) => {
const profileSessions = getPaseoBrowserProfileSessions(
session,
readLegacyPaseoBrowserIds(rawLegacyBrowserIds),
);
const profileSession = profileSessions[0];
await clearPaseoBrowserProfile({
profileSessions,
listGuests: () =>
listPaseoBrowserProfileGuests({
profileSession,
webContents: webContents.getAllWebContents(),
}),
logReloadError: (webContentsId, error) => {
log.warn("[browser-profile] failed to reload guest", { webContentsId, error });
},
});
});
const browserCapture = createBrowserCaptureService<Electron.NativeImage>({
findGuest: getPaseoBrowserWebContentsForHostWindow,
decodeImage: (dataUrl) => nativeImage.createFromDataURL(dataUrl),
clipboard: {
write: async ({ text, image }) => {
const items: Record<string, string | Blob> = {};
if (text) items["text/plain"] = text;
if (image) {
const png = image.toPNG();
const bytes = new Uint8Array(png.byteLength);
bytes.set(png);
items["image/png"] = new Blob([bytes], { type: "image/png" });
}
await clipboard.write([new ClipboardItem(items)]);
},
},
warn: (event, details) => log.warn(`[browser-capture] ${event}`, details),
});
ipcMain.handle("paseo:browser:capture-element", (event, browserId: unknown, rect: unknown) =>
browserCapture.capture({ browserId, hostWebContentsId: event.sender.id, rect }),
);
ipcMain.handle("paseo:browser:copy-element", (_event, payload: unknown) =>
browserCapture.copy(payload),
);
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: { standard: true, secure: true, supportFetchAPI: true },
},
]);
// ---------------------------------------------------------------------------
// Window creation
// ---------------------------------------------------------------------------
function getPreloadPath(): string {
return path.join(__dirname, "preload.js");
}
function getBrowserKeyboardPreloadPath(): string {
return path.join(__dirname, "features", "browser-keyboard", "guest-preload.js");
}
function getAppDistDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "app-dist");
}
return path.resolve(__dirname, "../../app/dist");
}
function getWindowIconCandidates(): string[] {
if (app.isPackaged) {
if (process.platform === "win32") {
return [
path.join(process.resourcesPath, "icon.ico"),
path.join(process.resourcesPath, "icon.png"),
];
}
return [path.join(process.resourcesPath, "icon.png")];
}
if (process.platform === "win32") {
return [
path.resolve(__dirname, "../assets/icon-dev.png"),
path.resolve(__dirname, "../assets/icon.ico"),
path.resolve(__dirname, "../assets/icon.png"),
];
}
return [
path.resolve(__dirname, "../assets/icon-dev.png"),
path.resolve(__dirname, "../assets/icon.png"),
];
}
function getWindowIconPath(): string | null {
const candidates = getWindowIconCandidates();
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
function getDevBuildLabel(): string | null {
if (app.isPackaged) {
return null;
}
return process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
}
let cachedEffectiveIconPath: string | null = null;
async function getEffectiveAppIconPath(): Promise<string | null> {
if (cachedEffectiveIconPath !== null) {
return cachedEffectiveIconPath;
}
const baseIconPath = getWindowIconPath();
if (app.isPackaged || !baseIconPath) {
cachedEffectiveIconPath = baseIconPath;
return baseIconPath;
}
const devLabel = getDevBuildLabel();
cachedEffectiveIconPath = await resolveAppIconPath({
isPackaged: false,
baseIconPath,
devLabel,
cacheDir: app.getPath("userData"),
});
return cachedEffectiveIconPath;
}
async function applyAppIcon(): Promise<void> {
// Packaged apps keep the bundle icon and its macOS system appearance.
if (app.isPackaged || process.platform !== "darwin") {
return;
}
const iconPath = await getEffectiveAppIconPath();
if (!iconPath) {
return;
}
const icon = nativeImage.createFromPath(iconPath);
if (icon.isEmpty()) {
return;
}
app.dock?.setIcon(icon);
}
// Work areas with the primary display first, so window-state clamping treats
// it as the fallback. getAllDisplays() order is not guaranteed to lead with it.
function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
const primary = screen.getPrimaryDisplay();
const others = screen.getAllDisplays().filter((display) => display.id !== primary.id);
return [primary, ...others].map((display) => display.workArea);
}
async function createWindow(
options: {
initialRoute?: string | null;
restoreWindowState?: boolean;
onCreated?: (webContentsId: number) => void;
onClosed?: (webContentsId: number) => void;
} = {},
): Promise<BrowserWindow> {
const iconPath = await getEffectiveAppIconPath();
const systemTheme = resolveSystemWindowTheme();
// Only the first window of a session restores and persists saved geometry.
// Additional windows (⌘N, second-instance, "Open in new window") open at the
// default size and let the OS cascade them, so they neither stack on top of
// the restored window nor fight over the single window-state store.
const restoreWindowState = options.restoreWindowState ?? false;
const windowStateStore = restoreWindowState
? createWindowStateStore({ userDataPath: app.getPath("userData") })
: null;
const savedWindowState = windowStateStore ? await windowStateStore.load() : null;
const restoredWindowState = savedWindowState
? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst())
: null;
const title = devWorktreeName ? `${APP_NAME} (${devWorktreeName})` : APP_NAME;
const mainWindow = new BrowserWindow({
title,
...resolveWindowBounds(restoredWindowState),
show: false,
backgroundColor: getWindowBackgroundColor(systemTheme),
...(iconPath ? { icon: iconPath } : {}),
...getMainWindowChromeOptions({
mode: DESKTOP_WINDOW_CHROME_MODE,
}),
webPreferences: {
preload: getPreloadPath(),
additionalArguments: [windowChromeModeArgument(DESKTOP_WINDOW_CHROME_MODE)],
contextIsolation: true,
nodeIntegration: false,
webviewTag: true,
},
});
applyDesktopWindowChromeMode({ win: mainWindow, mode: DESKTOP_WINDOW_CHROME_MODE });
const webContentsId = mainWindow.webContents.id;
options.onCreated?.(webContentsId);
mainWindow.webContents.on("did-start-navigation", (_event, _url, isSameDocument, isMainFrame) => {
if (isMainFrame && !isSameDocument) {
agentNavigationInbox.windowLoading(webContentsId);
}
});
mainWindow.on("closed", () => {
options.onClosed?.(webContentsId);
agentNavigationInbox.removeWindow(webContentsId);
unregisterPaseoBrowserHost(webContentsId);
browserKeyboard.detachHost(webContentsId);
});
if (devWorktreeName) {
app.dock?.setBadge(devWorktreeName);
}
if (restoredWindowState?.isMaximized) {
mainWindow.maximize();
}
setupDarwinCompositorWatchdog(mainWindow);
setupWindowResizeEvents(mainWindow);
if (windowStateStore) {
setupWindowStatePersistence(mainWindow, windowStateStore);
}
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
if (!isPaseoBrowserWebviewAttach(params)) {
event.preventDefault();
return;
}
webPreferences.nodeIntegration = false;
// The sandboxed keyboard preload must run in every frame so focused iframes keep
// the same page-first shortcut boundary. Node integration remains disabled.
webPreferences.nodeIntegrationInSubFrames = true;
webPreferences.nodeIntegrationInWorker = false;
webPreferences.contextIsolation = true;
webPreferences.sandbox = true;
webPreferences.webSecurity = true;
webPreferences.webviewTag = false;
webPreferences.allowRunningInsecureContent = false;
delete webPreferences.preload;
delete params.preload;
delete (webPreferences as { preloadURL?: string }).preloadURL;
delete (params as { preloadURL?: string }).preloadURL;
webPreferences.preload = getBrowserKeyboardPreloadPath();
});
mainWindow.webContents.on("did-attach-webview", (_event, contents) => {
preparePaseoBrowserWebContents(contents);
contents.once("destroyed", () => {
pendingBrowserWindowOpenRequests.delete(contents.id);
});
installBrowserWindowOpenHandler({
contents,
sourceContents: contents,
mainWindow,
});
contents.on("context-menu", (_contextMenuEvent, params) => {
showBrowserWebviewContextMenu(mainWindow, contents, params);
});
registerBrowserWebviewNavigationGuards(contents);
});
showWindowWhenReady(mainWindow, {
showOnLoad: process.platform === "linux",
});
if (!app.isPackaged) {
const { loadReactDevTools } = await import("./features/react-devtools.js");
await loadReactDevTools();
const initialUrl = options.initialRoute
? new URL(options.initialRoute, `${DEV_SERVER_URL}/`).toString()
: DEV_SERVER_URL;
await mainWindow.loadURL(initialUrl);
return mainWindow;
}
await mainWindow.loadURL(`${APP_SCHEME}://app${options.initialRoute ?? "/"}`);
return mainWindow;
}
function ownedDesktopWindow(win: BrowserWindow): OwnedDesktopWindow<AgentDeepLinkTarget> {
return {
webContentsId: win.webContents.id,
isDestroyed: () => win.isDestroyed(),
isVisible: () => win.isVisible(),
isMinimized: () => win.isMinimized(),
restore: () => win.restore(),
show: () => win.show(),
focus: () => win.focus(),
sendAgent: (target) => win.webContents.send("paseo:event:open-agent", target),
};
}
desktopWindowOwner = createDesktopWindowOwner<AgentDeepLinkTarget>({
async create(input) {
const win = await createWindow({
initialRoute: input.initialRoute,
restoreWindowState: input.restoreWindowState,
onCreated: input.onCreated,
onClosed: input.onClosed,
});
return ownedDesktopWindow(win);
},
windows: () => BrowserWindow.getAllWindows().map(ownedDesktopWindow),
focusedWindow: () => {
const win = BrowserWindow.getFocusedWindow();
if (!win) return null;
return ownedDesktopWindow(win);
},
agentRoute: buildAgentDeepLinkRoute,
deliverAgent: (webContentsId, target) =>
agentNavigationInbox.deliverOrQueue(webContentsId, target),
});
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
function receiveAgentDeepLink(input: string): void {
const target = parseAgentDeepLink(input);
if (!target) {
return;
}
if (bootstrapIsComplete) {
void desktopWindowOwner
.openOrFocusAgent(target)
.catch((error) => log.error("[window] failed to route agent link", error));
return;
}
pendingAgentNavigation = target;
void bootstrapComplete.then(() => {
if (pendingAgentNavigation !== target) {
return undefined;
}
pendingAgentNavigation = null;
void desktopWindowOwner
.openOrFocusAgent(target)
.catch((error) => log.error("[window] failed to route queued agent link", error));
return undefined;
});
}
app.on("open-url", (event, url) => {
event.preventDefault();
receiveAgentDeepLink(url);
});
function setupSingleInstanceLock(): boolean {
if (DISABLE_SINGLE_INSTANCE_LOCK) {
log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK");
return true;
}
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
return false;
}
app.on("second-instance", (_event, commandLine) => {
const agentTarget = parseAgentDeepLinkFromArgv(commandLine);
if (agentTarget) {
void bootstrapComplete
.then(() => desktopWindowOwner.openOrFocusAgent(agentTarget))
.catch((error) => log.error("[window] failed to route second-instance agent link", error));
return;
}
log.info("[open-project] second-instance commandLine:", commandLine);
const openProjectPath = parseOpenProjectPathFromArgv({
argv: commandLine,
isDefaultApp: false,
});
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
// Relaunching the app (CLI `paseo [path]`, double-click, etc.) opens a new
// window rather than focusing the existing one. Wait for bootstrap (not just
// app.whenReady) so the protocol + IPC handlers exist before the window loads.
void bootstrapComplete
.then(() => desktopWindowOwner.openAdditional({ pendingProjectPath: openProjectPath }))
.catch((error) => {
log.error("[window] failed to create window from second-instance", error);
});
});
return true;
}
async function runCliPassthroughIfRequested(): Promise<boolean> {
const cliArgs = parsePassthroughCliArgsFromArgv(process.argv);
if (!cliArgs) {
return false;
}
try {
const exitCode = await runPassthroughCli(cliArgs);
app.exit(exitCode);
} catch (error) {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
app.exit(1);
}
return true;
}
async function bootstrap(): Promise<void> {
if (!setupSingleInstanceLock()) {
return;
}
await app.whenReady();
const appDistDir = getAppDistDir();
protocol.handle(APP_SCHEME, (request) => {
const { pathname, search, hash } = new URL(request.url);
const decodedPath = decodeURIComponent(pathname);
// Chromium can occasionally request the exported entrypoint directly.
// Canonicalize it back to the route URL so Expo Router sees `/`, not `/index.html`.
if (decodedPath.endsWith("/index.html")) {
const normalizedPath = decodedPath.slice(0, -"/index.html".length) || "/";
return Response.redirect(`${APP_SCHEME}://app${normalizedPath}${search}${hash}`, 307);
}
const filePath = path.join(appDistDir, decodedPath);
const relativePath = path.relative(appDistDir, filePath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
return new Response("Not found", { status: 404 });
}
// SPA fallback: serve index.html for routes without a file extension
if (!relativePath || !path.extname(relativePath)) {
return net.fetch(pathToFileURL(path.join(appDistDir, "index.html")).toString());
}
return net.fetch(pathToFileURL(filePath).toString());
});
await applyAppIcon();
setupApplicationMenu({
onNewWindow: () => {
void desktopWindowOwner.openAdditional().catch((error) => {
log.error("[window] failed to create window from menu", error);
});
},
});
ensureNotificationCenterRegistration();
registerDaemonManager();
registerWindowManager({ mode: DESKTOP_WINDOW_CHROME_MODE });
registerDialogHandlers();
registerNotificationHandlers();
const openExternalUrl = createExternalUrlOpener({ open: shell.openExternal });
ipcMain.handle("paseo:opener:openUrl", (_event, value: unknown) => openExternalUrl(value));
registerEditorTargetHandlers();
registerBrowserAutomationIpc();
// In-app "Open in new window": opens a window that lands on the given project
// via the same open-project flow as a CLI launch (no move, no ownership).
ipcMain.handle("paseo:window:openNew", async (_event, options?: unknown) => {
const pendingPath =
options && typeof options === "object" && "pendingOpenProjectPath" in options
? (options as { pendingOpenProjectPath?: unknown }).pendingOpenProjectPath
: null;
await desktopWindowOwner.openAdditional({
pendingProjectPath: typeof pendingPath === "string" ? pendingPath : null,
});
});
// The first window of the session restores and persists saved geometry.
const initialAgentNavigation = pendingAgentNavigation;
pendingAgentNavigation = null;
await desktopWindowOwner.openPrimary({
initialRoute: initialAgentNavigation ? buildAgentDeepLinkRoute(initialAgentNavigation) : null,
pendingProjectPath: pendingOpenProjectPath,
});
pendingOpenProjectPath = null;
// Protocol + IPC handlers and the first window now exist: release any
// second-instance launches that arrived during cold start.
bootstrapIsComplete = true;
resolveBootstrapComplete();
if (pendingAgentNavigation) {
const target = pendingAgentNavigation;
pendingAgentNavigation = null;
await desktopWindowOwner.openOrFocusAgent(target);
}
app.on("activate", () => {
void desktopWindowOwner.restoreWhenActivated().catch((error) => {
console.error("Failed to restore a desktop window after activation", error);
});
});
}
void runDesktopStartup({
hasPendingGuiLaunchRequest: Boolean(pendingOpenProjectPath || pendingAgentNavigation),
runCliPassthroughIfRequested,
inheritLoginShellEnv,
bootstrapGui: bootstrap,
}).catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
function showDaemonShutdownDialog(): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send("paseo:event:quitting", {});
}
}
const quitLifecycle = createQuitLifecycle({
app,
closeTransportSessions: closeAllTransportSessions,
stopDesktopManagedDaemonIfNeeded: () =>
stopDesktopManagedDaemonOnQuitIfNeeded({
settingsStore: getDesktopSettingsStore(),
isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync,
stopDaemon: () => stopDesktopDaemonViaCli("quit"),
showShutdownFeedback: showDaemonShutdownDialog,
}),
installAppUpdateOnQuit: async (signal) => {
const settings = await getDesktopSettingsStore().get();
return installAppUpdateOnQuit({
currentVersion: app.getVersion(),
releaseChannel: settings.releaseChannel,
signal,
});
},
createUpdateDeadlineSignal: () => AbortSignal.timeout(UPDATE_QUIT_DEADLINE_MS),
onStopError: (error) => {
log.error("[desktop daemon] failed to stop managed daemon on quit", error);
},
onUpdateError: (error) => {
log.error("[auto-updater] failed to validate downloaded update on quit", error);
},
});
// electron-updater forwards this event through Electron's built-in autoUpdater.
electronAutoUpdater.on("before-quit-for-update", () => {
log.info("[auto-updater] before-quit-for-update", { currentVersion: app.getVersion() });
quitLifecycle.handleBeforeQuitForUpdate();
});
app.on("before-quit", quitLifecycle.handleBeforeQuit);
registerExternalQuitSignals({ signals: process, quit: () => app.quit() });
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});