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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 179 additions & 5 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { dirname, join, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { homedir } from "node:os";
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { listInstalledFonts } from "./system-fonts";
import {
APP_ID,
Expand Down Expand Up @@ -49,6 +49,7 @@ import {
type AskToolResolution,
type AppMenuCommand,
type AppNotification,
type CloseBehavior,
type CommandShellCatalog,
type CommandShellId,
type GlobalPermissionMode,
Expand Down Expand Up @@ -91,6 +92,7 @@ import type {
PluginNotificationPermission,
} from "@pi-desktop/plugin-sdk";
import { resolvePluginLocalizedString } from "@pi-desktop/plugin-sdk";

import { HostProcess } from "./host-process";
import { PersistenceOutbox } from "./persistence-outbox";
import { AgentSidecar } from "./agent-sidecar";
Expand Down Expand Up @@ -183,6 +185,13 @@ let sidecar: AgentSidecar | null = null;
let quitting = false;
let shutdownComplete = false;
let shutdownPromise: Promise<void> | null = null;
// Windows/Linux system-tray presence. When set, closing the main window
// hides it to the tray instead of quitting the app (close-to-tray).
// User-chosen close behavior on Windows/Linux; "ask" prompts on first close.
let closeBehavior: CloseBehavior = "ask";
let closePromptOpen = false;
let allowWindowClose = false;

let pluginNotificationPermission: PluginNotificationPermission = "unknown";
const pluginNativeNotifications = new Set<SystemNotification>();
const PLUGIN_NOTIFICATION_TIMEOUT_MS = 2_000;
Expand Down Expand Up @@ -258,6 +267,7 @@ const pluginPanels = new PluginPanelHost(
data: { api: "panel.egress", ok: false, url, ts: Date.now() },
});
},

);
const plugins = new PluginRuntime({
getWorkspacePath: () => {
Expand Down Expand Up @@ -960,13 +970,51 @@ function updateTrayMenu(locale = app.getLocale()) {
const labels = resolveLocale(locale) === "zh-CN" ? zhCN.tray : en.tray;
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: labels.show, click: restoreMainWindow },
{ label: labels.open, click: restoreMainWindow },
{ type: "separator" },
{ label: labels.quit, click: () => app.quit() },
]),
);
}

/** Restores a tray-hidden or minimized main window, or recreates it. */
function revealMainWindow() {
const window = mainWindow;
if (!window || window.isDestroyed()) {
void ensureWindow();
return;
}
if (window.isMinimized()) window.restore();
window.show();
window.focus();
}

/**
* Windows/Linux tray icon for close-to-tray: closing the main window hides
* it to the tray (the app keeps running in the background) and the tray
* restores it. macOS keeps the native Dock lifecycle and gets no tray.
*/
function createTrayIcon() {
if (process.platform === "darwin" || tray) return;
const iconPath = join(app.getAppPath(), "build", "icon.png");
const icon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
if (icon.isEmpty()) {
logger.app("lifecycle", "warn", "tray icon missing", { data: { iconPath } });
return;
}
const labels = resolveLocale(app.getLocale()) === "zh-CN" ? zhCN : en;
tray = new Tray(icon);
tray.setToolTip(APP_NAME);
tray.on("click", revealMainWindow);
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: labels.tray.open, click: revealMainWindow },
{ type: "separator" },
{ label: labels.tray.quit, click: () => app.quit() },
]),
);
}

function createTray() {
if (tray) return;
const iconPath = trayIconPath();
Expand Down Expand Up @@ -997,6 +1045,7 @@ function createTray() {
updateTrayMenu();
}


function sendToRenderer(channel: string, payload: unknown) {
if (!IPC_WHITELIST.has(channel)) return;
const window = mainWindow;
Expand Down Expand Up @@ -1345,6 +1394,61 @@ function writeWindowState(state: WindowState) {
}
}

function closeBehaviorPath() {
return join(dataDir, "close-behavior.json");
}

function readCloseBehavior(): CloseBehavior | null {
try {
const raw = JSON.parse(readFileSync(closeBehaviorPath(), "utf8"));
return raw === "ask" || raw === "tray" || raw === "quit" ? raw : null;
} catch {
return null;
}
}

function writeCloseBehavior(behavior: CloseBehavior) {
try {
mkdirSync(dataDir, { recursive: true });
writeFileSync(closeBehaviorPath(), JSON.stringify(behavior), "utf8");
} catch {
// best-effort persistence
}
}

/** Applies a close-behavior choice and reconciles the tray icon with it. */
function applyCloseBehavior(next: CloseBehavior) {
closeBehavior = next;
writeCloseBehavior(next);
if (next === "tray") createTrayIcon();
else if (tray) {
tray.destroy();
tray = null;
}
}

/**
* First-close prompt on Windows/Linux: asks whether closing the window
* should hide the app to the tray or exit it. The choice is persisted and
* can be changed later in Settings. Returns null when the user cancels.
*/
async function askCloseBehavior(
window: BrowserWindow,
): Promise<"tray" | "quit" | null> {
const labels = resolveLocale(app.getLocale()) === "zh-CN" ? zhCN : en;
const { response } = await dialog.showMessageBox(window, {
type: "question",
title: labels.tray.askTitle,
message: labels.tray.askTitle,
detail: labels.tray.askBody,
buttons: [labels.common.cancel, labels.tray.closeToTray, labels.tray.quit],
defaultId: 1,
cancelId: 0,
noLink: true,
});
return response === 1 ? "tray" : response === 2 ? "quit" : null;
}

function workPanelMinimumWindowWidth() {
return WINDOW_MIN_WIDTH + workPanelReservation.width;
}
Expand Down Expand Up @@ -1851,7 +1955,17 @@ async function createWindow() {
};

const ensureStableBounds = (force = false) => {
if (!isLiveWindow() || boundsGuard || captureViewportOverride) return;
if (
!isLiveWindow() ||
boundsGuard ||
captureViewportOverride ||
// Never fight the user's own state: a minimized window stays
// minimized and a tray-hidden window stays hidden.
window.isMinimized() ||
!window.isVisible()
) {
return;
}
const electronBounds = window.getBounds();
const cg = readCgBounds();
if (!cg && cgHelperAvailable) missingCgStreak += 1;
Expand Down Expand Up @@ -1953,12 +2067,45 @@ async function createWindow() {
};
window.on("resize", scheduleStateSave);
window.on("move", scheduleStateSave);
window.on("close", () => {
window.on("close", (event) => {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
persistNormalWindowState();
// Windows/Linux close-behavior: "tray" hides the window and keeps the
// app running under the tray icon, "ask" prompts on the first close,
// and "quit" (plus macOS and explicit-quit closes) falls through to the
// default close.
if (process.platform === "darwin" || quitting || allowWindowClose) return;
event.preventDefault();
void (async () => {
if (closeBehavior === "ask") {
if (closePromptOpen) return;
closePromptOpen = true;
try {
const choice = await askCloseBehavior(window);
if (!choice) return; // canceled: keep the window open
applyCloseBehavior(choice);
} finally {
closePromptOpen = false;
}
}
if (closeBehavior === "tray") {
createTrayIcon();
// The tray is the only way back to a hidden window; if the icon
// could not be created, fall back to a real close instead of
// leaving the app invisible.
if (tray) window.hide();
else {
allowWindowClose = true;
window.close();
}
} else {
allowWindowClose = true;
window.close();
}
})();
});

const boundsWatchdog = setInterval(() => {
Expand Down Expand Up @@ -5437,6 +5584,26 @@ function registerIpc() {
},
);

// Close-behavior preference (Windows/Linux): read/write the choice the
// settings UI and the first-close prompt share. Only "tray" and "quit"
// are settable — the "ask" state is transient (first close prompts once)
// and once a choice is made it cannot be reverted to prompting.
handle(IPC.invoke.closeBehaviorGet, async () => ({
behavior: closeBehavior,
supported: process.platform !== "darwin",
}));

handle(IPC.invoke.closeBehaviorSet, async (input: unknown = {}) => {
const behavior = (input as { behavior?: unknown })?.behavior;
if (behavior !== "tray" && behavior !== "quit") {
throw Object.assign(new Error("invalid close behavior"), {
errorCode: ErrorCodes.INVALID_ARGUMENT,
});
}
applyCloseBehavior(behavior);
return { behavior };
});

ipcMain.handle(IPC.invoke.menuRendererReady, async (event) =>
wrap(async () => {
const window = BrowserWindow.fromWebContents(event.sender);
Expand Down Expand Up @@ -6649,6 +6816,9 @@ app.whenReady().then(async () => {
}
}
await ensureWindow();
const storedBehavior = readCloseBehavior();
if (storedBehavior) closeBehavior = storedBehavior;
if (closeBehavior === "tray") createTrayIcon();
// createWindow awaits the initial load (loadFile resolves on
// did-finish-load), so the page is up; give React a beat to mount its
// event subscriptions before pushing the boot outcome.
Expand Down Expand Up @@ -6744,7 +6914,11 @@ app.whenReady().then(async () => {
});

app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
// With a tray present the app owns a background surface: a window closed
// or destroyed for any reason must not take the whole app down — the
// tray click recreates it. Without a tray, closing the last window
// (Windows/Linux) exits the app as before.
if (process.platform !== "darwin" && !tray) app.quit();
});

app.on("before-quit", (event) => {
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import type {
PlansPendingResult,
UpdateState,
WindowControlAction,
CloseBehavior,
} from "@pi-desktop/shared";
import {
defaultCommandShellForPlatform,
Expand Down Expand Up @@ -602,6 +603,14 @@ export const api = {
),
windowControl: (action: WindowControlAction) =>
invoke<{ maximized: boolean }>(IPC.invoke.windowControl, { action }),
getCloseBehavior: () =>
invoke<{ behavior: CloseBehavior; supported: boolean }>(
IPC.invoke.closeBehaviorGet,
),
setCloseBehavior: (behavior: CloseBehavior) =>
invoke<{ behavior: CloseBehavior }>(IPC.invoke.closeBehaviorSet, {
behavior,
}),
menuRendererReady: () =>
invoke<{ ready: boolean }>(IPC.invoke.menuRendererReady),
nativeMenuAction: (action: NativeMenuAction) =>
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/lib/settings-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export const SETTINGS_NAV: SettingsNavEntry[] = [
"settings.theme",
"settings.language",
"settings.font",
"settings.closeBehaviorTitle",
"settings.closeBehaviorTray",
"settings.closeBehaviorQuit",
"settings.defaultsTitle",
"settings.mode",
"settings.commandShell",
Expand Down
Loading
Loading