@@ -53,6 +53,7 @@ import {
|
||||
setWorkspaceActivePaseoBrowserId,
|
||||
} from "./features/browser-webviews.js";
|
||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||
import { PendingOpenProjectStore } from "./pending-open-project-store.js";
|
||||
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
||||
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
|
||||
import {
|
||||
@@ -94,7 +95,6 @@ function preventUnsafeBrowserWebviewNavigation(
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
||||
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
|
||||
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
|
||||
|
||||
@@ -257,6 +257,12 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
||||
isDefaultApp: process.defaultApp,
|
||||
});
|
||||
|
||||
// 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.
|
||||
const pendingOpenProjectStore = new PendingOpenProjectStore();
|
||||
|
||||
if (PASEO_DEBUG) {
|
||||
log.info("[open-project] argv:", process.argv);
|
||||
log.info("[open-project] isDefaultApp:", process.defaultApp);
|
||||
@@ -265,10 +271,13 @@ if (PASEO_DEBUG) {
|
||||
|
||||
// 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;
|
||||
ipcMain.handle("paseo:get-pending-open-project", (event) => {
|
||||
const webContentsId = event.sender.id;
|
||||
const result = pendingOpenProjectStore.take(webContentsId);
|
||||
log.info("[open-project] renderer requested pending path:", {
|
||||
webContentsId,
|
||||
pendingPath: result,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -326,7 +335,10 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow
|
||||
});
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
||||
{
|
||||
scheme: APP_SCHEME,
|
||||
privileges: { standard: true, secure: true, supportFetchAPI: true },
|
||||
},
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -395,12 +407,24 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
|
||||
return [primary, ...others].map((display) => display.workArea);
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<void> {
|
||||
async function createWindow(
|
||||
options: {
|
||||
pendingOpenProjectPath?: string | null;
|
||||
restoreWindowState?: boolean;
|
||||
} = {},
|
||||
): Promise<BrowserWindow> {
|
||||
const iconPath = getWindowIconPath();
|
||||
const systemTheme = resolveSystemWindowTheme();
|
||||
|
||||
const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") });
|
||||
const savedWindowState = await windowStateStore.load();
|
||||
// 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;
|
||||
@@ -424,6 +448,12 @@ async function createMainWindow(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
const webContentsId = mainWindow.webContents.id;
|
||||
pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath);
|
||||
mainWindow.on("closed", () => {
|
||||
pendingOpenProjectStore.delete(webContentsId);
|
||||
});
|
||||
|
||||
if (devWorktreeName) {
|
||||
app.dock?.setBadge(devWorktreeName);
|
||||
}
|
||||
@@ -434,7 +464,9 @@ async function createMainWindow(): Promise<void> {
|
||||
|
||||
setupDarwinCompositorWatchdog(mainWindow);
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupWindowStatePersistence(mainWindow, windowStateStore);
|
||||
if (windowStateStore) {
|
||||
setupWindowStatePersistence(mainWindow, windowStateStore);
|
||||
}
|
||||
setupDefaultContextMenu(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
|
||||
@@ -531,31 +563,27 @@ async function createMainWindow(): Promise<void> {
|
||||
const { loadReactDevTools } = await import("./features/react-devtools.js");
|
||||
await loadReactDevTools();
|
||||
await mainWindow.loadURL(DEV_SERVER_URL);
|
||||
return;
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
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();
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Resolves once bootstrap() has registered the custom protocol handler and IPC
|
||||
// handlers and created the first window. second-instance window creation waits
|
||||
// on this rather than app.whenReady(): in packaged mode createWindow loads
|
||||
// `paseo://app/`, which fails if the protocol handler isn't registered yet, and
|
||||
// a second instance can arrive mid-cold-start.
|
||||
let resolveBootstrapComplete: () => void;
|
||||
const bootstrapComplete = new Promise<void>((resolve) => {
|
||||
resolveBootstrapComplete = resolve;
|
||||
});
|
||||
|
||||
function setupSingleInstanceLock(): boolean {
|
||||
if (DISABLE_SINGLE_INSTANCE_LOCK) {
|
||||
log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK");
|
||||
@@ -575,15 +603,14 @@ function setupSingleInstanceLock(): boolean {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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(() => createWindow({ pendingOpenProjectPath: openProjectPath }))
|
||||
.catch((error) => {
|
||||
log.error("[window] failed to create window from second-instance", error);
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -689,7 +716,13 @@ async function bootstrap(): Promise<void> {
|
||||
});
|
||||
|
||||
applyAppIcon();
|
||||
setupApplicationMenu();
|
||||
setupApplicationMenu({
|
||||
onNewWindow: () => {
|
||||
void createWindow().catch((error) => {
|
||||
log.error("[window] failed to create window from menu", error);
|
||||
});
|
||||
},
|
||||
});
|
||||
ensureNotificationCenterRegistration();
|
||||
if (await runDesktopSmokeIfRequested()) {
|
||||
return;
|
||||
@@ -701,11 +734,29 @@ async function bootstrap(): Promise<void> {
|
||||
registerOpenerHandlers();
|
||||
registerEditorTargetHandlers();
|
||||
|
||||
await createMainWindow();
|
||||
// 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 createWindow({
|
||||
pendingOpenProjectPath: typeof pendingPath === "string" ? pendingPath : null,
|
||||
});
|
||||
});
|
||||
|
||||
// The first window of the session restores and persists saved geometry.
|
||||
await createWindow({ pendingOpenProjectPath, restoreWindowState: true });
|
||||
pendingOpenProjectPath = null;
|
||||
|
||||
// Protocol + IPC handlers and the first window now exist: release any
|
||||
// second-instance launches that arrived during cold start.
|
||||
resolveBootstrapComplete();
|
||||
|
||||
app.on("activate", async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await createMainWindow();
|
||||
await createWindow({ restoreWindowState: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user