Skills installed via Paseo desktop never updated after first run, so users were stuck on already-fixed skill content. On every launch, if the paseo base skill is present in `~/.agents/skills/`, sync each bundled skill (incl. `references/`) into agents, claude (via symlink/ junction), and codex — overwriting only files whose content differs. Files on disk that are not in the bundle are left alone; deprecation goes through tombstones (e.g. `paseo-orchestrate` redirects to `paseo-epic`). Also refreshes the bundled skill set: drops `paseo-chat`, adds `paseo-advisor` and `paseo-epic`, and turns `paseo-orchestrate` into a tombstone redirecting to `paseo-epic`. Adds a desktop test job to CI on Ubuntu and Windows so the junction/symlink path is exercised on both platforms.
445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
import log from "electron-log/main";
|
|
log.transports.console.level = "info";
|
|
log.initialize({ spyRendererConsole: true });
|
|
|
|
import { inheritLoginShellEnv } from "./login-shell-env.js";
|
|
inheritLoginShellEnv();
|
|
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { existsSync } from "node:fs";
|
|
import { execFileSync } from "node:child_process";
|
|
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
|
|
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
|
|
import {
|
|
parseCliPassthroughArgsFromArgv,
|
|
runCliPassthroughCommand,
|
|
} from "./daemon/runtime-paths.js";
|
|
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
|
import {
|
|
registerWindowManager,
|
|
getMainWindowChromeOptions,
|
|
getWindowBackgroundColor,
|
|
resolveSystemWindowTheme,
|
|
setupDarwinPaintRefresh,
|
|
setupWindowResizeEvents,
|
|
setupDefaultContextMenu,
|
|
setupDragDropPrevention,
|
|
} from "./window/window-manager.js";
|
|
import { registerDialogHandlers } from "./features/dialogs.js";
|
|
import {
|
|
registerNotificationHandlers,
|
|
ensureNotificationCenterRegistration,
|
|
} from "./features/notifications.js";
|
|
import { registerOpenerHandlers } from "./features/opener.js";
|
|
import { setupApplicationMenu } from "./features/menu.js";
|
|
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
|
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
|
import {
|
|
isDesktopManagedDaemonRunningSync,
|
|
stopDesktopDaemonViaCli,
|
|
} from "./daemon/daemon-manager.js";
|
|
import {
|
|
createBeforeQuitHandler,
|
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
|
} from "./daemon/quit-lifecycle.js";
|
|
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
|
|
|
|
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
|
const APP_SCHEME = "paseo";
|
|
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
|
const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE";
|
|
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
|
|
app.setName("Paseo");
|
|
|
|
// 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,
|
|
}).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,
|
|
}).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;
|
|
}
|
|
}
|
|
|
|
// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID
|
|
// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the
|
|
// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code.
|
|
if (process.platform === "linux" && process.env.APPIMAGE) {
|
|
app.commandLine.appendSwitch("no-sandbox");
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
|
argv: process.argv,
|
|
isDefaultApp: process.defaultApp,
|
|
});
|
|
|
|
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", () => {
|
|
log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath);
|
|
const result = pendingOpenProjectPath;
|
|
pendingOpenProjectPath = null;
|
|
return result;
|
|
});
|
|
|
|
protocol.registerSchemesAsPrivileged([
|
|
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Window creation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getPreloadPath(): string {
|
|
return path.join(__dirname, "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.ico"),
|
|
path.resolve(__dirname, "../assets/icon.png"),
|
|
];
|
|
}
|
|
return [path.resolve(__dirname, "../assets/icon.png")];
|
|
}
|
|
|
|
function getWindowIconPath(): string | null {
|
|
const candidates = getWindowIconCandidates();
|
|
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
}
|
|
|
|
function applyAppIcon(): void {
|
|
if (process.platform !== "darwin") {
|
|
return;
|
|
}
|
|
|
|
const iconPath = path.resolve(__dirname, "../assets/icon.png");
|
|
if (!existsSync(iconPath)) {
|
|
return;
|
|
}
|
|
|
|
const icon = nativeImage.createFromPath(iconPath);
|
|
if (icon.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
app.dock?.setIcon(icon);
|
|
}
|
|
|
|
async function createMainWindow(): Promise<void> {
|
|
const iconPath = getWindowIconPath();
|
|
const systemTheme = resolveSystemWindowTheme();
|
|
|
|
const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo";
|
|
const mainWindow = new BrowserWindow({
|
|
title,
|
|
width: 1200,
|
|
height: 800,
|
|
show: false,
|
|
backgroundColor: getWindowBackgroundColor(systemTheme),
|
|
...(iconPath ? { icon: iconPath } : {}),
|
|
...getMainWindowChromeOptions({
|
|
platform: process.platform,
|
|
theme: systemTheme,
|
|
}),
|
|
webPreferences: {
|
|
preload: getPreloadPath(),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
},
|
|
});
|
|
|
|
if (devWorktreeName) {
|
|
app.dock?.setBadge(devWorktreeName);
|
|
}
|
|
|
|
setupDarwinPaintRefresh(mainWindow);
|
|
setupWindowResizeEvents(mainWindow);
|
|
setupDefaultContextMenu(mainWindow);
|
|
setupDragDropPrevention(mainWindow);
|
|
|
|
mainWindow.once("ready-to-show", () => {
|
|
mainWindow.show();
|
|
});
|
|
|
|
if (!app.isPackaged) {
|
|
const { loadReactDevTools } = await import("./features/react-devtools.js");
|
|
await loadReactDevTools();
|
|
await mainWindow.loadURL(DEV_SERVER_URL);
|
|
return;
|
|
}
|
|
|
|
await mainWindow.loadURL(`${APP_SCHEME}://app/`);
|
|
}
|
|
|
|
function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
|
|
const send = () => {
|
|
log.info("[open-project] sending event to renderer:", projectPath);
|
|
win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath });
|
|
};
|
|
|
|
if (win.webContents.isLoadingMainFrame()) {
|
|
log.info("[open-project] waiting for did-finish-load before sending event");
|
|
win.webContents.once("did-finish-load", send);
|
|
return;
|
|
}
|
|
|
|
send();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// App lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function setupSingleInstanceLock(): boolean {
|
|
const gotLock = app.requestSingleInstanceLock();
|
|
if (!gotLock) {
|
|
app.quit();
|
|
return false;
|
|
}
|
|
|
|
app.on("second-instance", (_event, commandLine) => {
|
|
log.info("[open-project] second-instance commandLine:", commandLine);
|
|
const openProjectPath = parseOpenProjectPathFromArgv({
|
|
argv: commandLine,
|
|
isDefaultApp: false,
|
|
});
|
|
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
|
|
const win = BrowserWindow.getAllWindows()[0];
|
|
if (win) {
|
|
win.show();
|
|
if (win.isMinimized()) win.restore();
|
|
win.focus();
|
|
if (openProjectPath) {
|
|
sendOpenProjectEvent(win, openProjectPath);
|
|
}
|
|
}
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
async function runCliPassthroughIfRequested(): Promise<boolean> {
|
|
const cliArgs = parseCliPassthroughArgsFromArgv(process.argv);
|
|
if (!cliArgs) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const exitCode = runCliPassthroughCommand(cliArgs);
|
|
process.exit(exitCode);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
process.stderr.write(`${message}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function runDesktopSmokeIfRequested(): Promise<boolean> {
|
|
if (process.env[DESKTOP_SMOKE_ENV] !== "1") {
|
|
return false;
|
|
}
|
|
|
|
const handlers = createDaemonCommandHandlers();
|
|
const startStatus = await handlers.start_desktop_daemon();
|
|
process.stdout.write(
|
|
`[paseo-smoke] ${JSON.stringify({
|
|
type: "desktop-daemon-smoke-started",
|
|
status: startStatus,
|
|
})}\n`,
|
|
);
|
|
|
|
await waitForDesktopSmokeStopRequest();
|
|
|
|
const stopStatus = await handlers.stop_desktop_daemon();
|
|
process.stdout.write(
|
|
`[paseo-smoke] ${JSON.stringify({
|
|
type: "desktop-daemon-smoke-stopped",
|
|
stopStatus,
|
|
})}\n`,
|
|
);
|
|
|
|
app.exit(0);
|
|
return true;
|
|
}
|
|
|
|
function waitForDesktopSmokeStopRequest(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
let buffer = "";
|
|
const stop = () => {
|
|
process.stdin.off("data", onData);
|
|
resolve();
|
|
};
|
|
const onData = (chunk: Buffer | string) => {
|
|
buffer += chunk.toString();
|
|
if (buffer.includes(DESKTOP_SMOKE_STOP_REQUEST)) {
|
|
stop();
|
|
}
|
|
};
|
|
|
|
process.stdin.on("data", onData);
|
|
process.stdin.resume();
|
|
});
|
|
}
|
|
|
|
async function bootstrap(): Promise<void> {
|
|
if (!pendingOpenProjectPath && (await runCliPassthroughIfRequested())) {
|
|
return;
|
|
}
|
|
|
|
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());
|
|
});
|
|
|
|
applyAppIcon();
|
|
setupApplicationMenu();
|
|
ensureNotificationCenterRegistration();
|
|
if (await runDesktopSmokeIfRequested()) {
|
|
return;
|
|
}
|
|
registerDaemonManager();
|
|
registerWindowManager();
|
|
registerDialogHandlers();
|
|
registerNotificationHandlers();
|
|
registerOpenerHandlers();
|
|
|
|
void autoUpdateSkillsIfInstalled().catch((error) => {
|
|
log.warn("[integrations] auto-update skills failed", error);
|
|
});
|
|
|
|
await createMainWindow();
|
|
|
|
app.on("activate", async () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
await createMainWindow();
|
|
}
|
|
});
|
|
}
|
|
|
|
void 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", {});
|
|
}
|
|
}
|
|
|
|
app.on(
|
|
"before-quit",
|
|
createBeforeQuitHandler({
|
|
app,
|
|
closeTransportSessions: closeAllTransportSessions,
|
|
stopDesktopManagedDaemonIfNeeded: () =>
|
|
stopDesktopManagedDaemonOnQuitIfNeeded({
|
|
settingsStore: getDesktopSettingsStore(),
|
|
isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync,
|
|
stopDaemon: stopDesktopDaemonViaCli,
|
|
showShutdownFeedback: showDaemonShutdownDialog,
|
|
}),
|
|
onStopError: (error) => {
|
|
log.error("[desktop daemon] failed to stop managed daemon on quit", error);
|
|
},
|
|
}),
|
|
);
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|