mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
* fix(desktop): preserve Linux sandboxing wherever the host allows it Chromium checks its Linux sandbox before main.js, while package installation probes run as root and miss AppArmor restrictions on desktop users. Choose the launch flags before exec and always install the SUID alternative for native packages. Test published artifacts as an ordinary user with namespaces both denied and allowed; never repair permissions in the smoke harness. * fix(desktop): retain the Linux release build baseline * fix(ci): allow canceled desktop smoke uploads to stop * test(desktop): verify the installed RPM sandbox under AppArmor * fix(ci): resolve packaged desktop dependencies before installation * chore(desktop): keep QA evidence out of the repository
1069 lines
36 KiB
TypeScript
1069 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 { 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);
|
|
});
|
|
|
|
mainWindow.once("ready-to-show", () => {
|
|
mainWindow.show();
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|