update
This commit is contained in:
1 parent
66158e222e
commit
67c757f481
314 files changed
+37945
-22962
No files matched your search
@@ -1,503 +1,40 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { extname, dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve, sep } from "node:path";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
net,
|
||||
protocol,
|
||||
session,
|
||||
shell,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { attachWindowStatePersistence, loadWindowState } from "./window-state";
|
||||
import type { NotificationOptions } from "./notification-service";
|
||||
import { createDesktopShellWindow, type DesktopShellWindowDependencies } from "./desktop-shell-window";
|
||||
import { createDesktopShellMenu } from "./desktop-shell-menu";
|
||||
|
||||
interface DesktopShellDependencies {
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
isDev: boolean;
|
||||
devServerUrl?: string;
|
||||
windowStatePath: string;
|
||||
shouldQuit: () => boolean;
|
||||
onRequestQuit: () => void;
|
||||
onCheckForUpdates: () => Promise<void>;
|
||||
onBackupProfile: () => Promise<void>;
|
||||
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
getActiveRunCount: () => number;
|
||||
}
|
||||
export function createDesktopShellService(deps: DesktopShellWindowDependencies) {
|
||||
const window = createDesktopShellWindow(deps);
|
||||
const menu = createDesktopShellMenu({
|
||||
iconPath: deps.iconPath,
|
||||
isDev: deps.isDev,
|
||||
onCheckForUpdates: deps.onCheckForUpdates,
|
||||
onBackupProfile: deps.onBackupProfile,
|
||||
onShortcut: deps.onShortcut,
|
||||
getMainWindow: window.getMainWindow,
|
||||
ensureWindow: window.ensureWindow,
|
||||
requestQuitWithConfirmation: window.requestQuitWithConfirmation,
|
||||
});
|
||||
|
||||
function isSocialBrowserSession(contents: Electron.WebContents) {
|
||||
return contents.session !== session.defaultSession;
|
||||
}
|
||||
|
||||
function isWithinDirectory(rootPath: string, targetPath: string) {
|
||||
const normalizedRoot = resolve(rootPath);
|
||||
const normalizedTarget = resolve(targetPath);
|
||||
return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`);
|
||||
}
|
||||
|
||||
export function createDesktopShellService({
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
shouldQuit,
|
||||
onRequestQuit,
|
||||
onCheckForUpdates,
|
||||
onBackupProfile,
|
||||
onShortcut,
|
||||
notify,
|
||||
getActiveRunCount,
|
||||
}: DesktopShellDependencies) {
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let appTray: Tray | null = null;
|
||||
let backgroundCloseNoticeShown = false;
|
||||
let windowCreationPromise: Promise<BrowserWindow | null> | null = null;
|
||||
let quitDialogShowing = false;
|
||||
|
||||
/**
|
||||
* Quit confirmation gate — shown when agents are actively running.
|
||||
* Gives the user three choices:
|
||||
* 0 = "Stop All & Quit" → terminate all agents and exit
|
||||
* 1 = "Minimize to Background" → hide window, agents keep running
|
||||
* 2 = "Cancel" → do nothing
|
||||
*
|
||||
* When no agents are active the app quits immediately without a dialog.
|
||||
*/
|
||||
async function requestQuitWithConfirmation() {
|
||||
if (quitDialogShowing) return;
|
||||
|
||||
let activeRunCount = 0;
|
||||
try {
|
||||
activeRunCount = getActiveRunCount();
|
||||
} catch {
|
||||
// Cannot determine active runs — proceed with immediate quit as fail-safe
|
||||
onRequestQuit();
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRunCount === 0) {
|
||||
onRequestQuit();
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
quitDialogShowing = true;
|
||||
try {
|
||||
const message = activeRunCount === 1
|
||||
? "1 agent run is currently active."
|
||||
: `${activeRunCount} agent runs are currently active.`;
|
||||
|
||||
const options: Electron.MessageBoxOptions = {
|
||||
type: "question",
|
||||
buttons: ["Stop All & Quit", "Minimize to Background", "Cancel"],
|
||||
defaultId: 2,
|
||||
cancelId: 2,
|
||||
title: "AgentCompany",
|
||||
message,
|
||||
detail: "Quitting will stop all running agents and their tasks. You can also minimize to keep agents running in the background.",
|
||||
};
|
||||
|
||||
const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
|
||||
const result = parentWindow
|
||||
? await dialog.showMessageBox(parentWindow, options)
|
||||
: await dialog.showMessageBox(options);
|
||||
|
||||
if (result.response === 0) {
|
||||
// Stop All & Quit
|
||||
onRequestQuit();
|
||||
app.quit();
|
||||
} else if (result.response === 1) {
|
||||
// Minimize to Background
|
||||
if (parentWindow) {
|
||||
parentWindow.hide();
|
||||
}
|
||||
if (!backgroundCloseNoticeShown) {
|
||||
backgroundCloseNoticeShown = true;
|
||||
notify({
|
||||
title: "Running in Background",
|
||||
body: "AgentCompany is still running in the system tray",
|
||||
urgency: "informational",
|
||||
navigation: { section: "overview" },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Cancel: do nothing
|
||||
} catch {
|
||||
// Dialog failed (e.g. window destroyed mid-flight) — quit as fail-safe
|
||||
onRequestQuit();
|
||||
app.quit();
|
||||
} finally {
|
||||
quitDialogShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getMainWindow() {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function installSecurityGuards() {
|
||||
session.defaultSession.setPermissionCheckHandler(() => false);
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
app.on("web-contents-created", (_event, contents) => {
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
// Allow external https URLs to open in the user's default browser
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
contents.on("will-attach-webview", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
contents.on("will-navigate", (event) => {
|
||||
if (isSocialBrowserSession(contents)) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerProtocol() {
|
||||
const textMimeTypes: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml; charset=utf-8",
|
||||
};
|
||||
protocol.handle("app", async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const relativePath = decodeURIComponent(url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, ""));
|
||||
const targetPath = resolve(rendererDist, relativePath);
|
||||
if (!isWithinDirectory(rendererDist, targetPath) || !existsSync(targetPath)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
const response = await net.fetch(pathToFileURL(targetPath).toString());
|
||||
const mime = textMimeTypes[extname(targetPath).toLowerCase()];
|
||||
if (mime) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Content-Type", mime);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
const windowIcon = nativeImage.createFromPath(iconPath);
|
||||
const savedWindowState = loadWindowState(windowStatePath);
|
||||
const isWindows = process.platform === "win32";
|
||||
const isLinux = process.platform === "linux";
|
||||
mainWindow = new BrowserWindow({
|
||||
title: "AgentCompany",
|
||||
icon: windowIcon.isEmpty() ? undefined : windowIcon,
|
||||
width: savedWindowState.bounds.width,
|
||||
height: savedWindowState.bounds.height,
|
||||
x: savedWindowState.bounds.x,
|
||||
y: savedWindowState.bounds.y,
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
backgroundColor: "#ffffff",
|
||||
frame: process.platform === "darwin",
|
||||
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
||||
...(isWindows || isLinux ? { titleBarOverlay: false } : {}),
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webviewTag: false,
|
||||
devTools: isDev,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync native theme with app to ensure Windows title bar matches content
|
||||
nativeTheme.themeSource = "light";
|
||||
|
||||
if (isDev && devServerUrl) {
|
||||
await mainWindow.loadURL(devServerUrl);
|
||||
} else {
|
||||
await mainWindow.loadURL("app://-/index.html");
|
||||
}
|
||||
attachWindowStatePersistence(mainWindow, windowStatePath);
|
||||
if (savedWindowState.maximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
mainWindow.on("close", (event) => {
|
||||
if (process.platform === "darwin" || shouldQuit()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
if (!backgroundCloseNoticeShown) {
|
||||
backgroundCloseNoticeShown = true;
|
||||
notify({
|
||||
title: "Running in Background",
|
||||
body: "AgentCompany is still running in the system tray",
|
||||
urgency: "informational",
|
||||
navigation: { section: "overview" },
|
||||
});
|
||||
}
|
||||
});
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureWindow() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
if (!windowCreationPromise) {
|
||||
windowCreationPromise = createWindow()
|
||||
.then(() => mainWindow)
|
||||
.finally(() => {
|
||||
windowCreationPromise = null;
|
||||
});
|
||||
}
|
||||
return windowCreationPromise;
|
||||
}
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
const macAppMenu: Electron.MenuItemConstructorOptions = {
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: `Quit ${app.name}`,
|
||||
accelerator: "CmdOrCtrl+Q",
|
||||
click: () => void requestQuitWithConfirmation(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(process.platform === "darwin" ? [macAppMenu] : []),
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "Open Workspace Folder",
|
||||
accelerator: "CmdOrCtrl+O",
|
||||
click: async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return;
|
||||
shell.openPath(result.filePaths[0] ?? "");
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Backup Profile",
|
||||
accelerator: "CmdOrCtrl+Shift+B",
|
||||
click: async () => {
|
||||
await onBackupProfile();
|
||||
},
|
||||
},
|
||||
...(process.platform === "darwin" ? [] : [
|
||||
{ type: "separator" as const },
|
||||
{
|
||||
label: "Quit",
|
||||
accelerator: "Alt+F4",
|
||||
click: () => void requestQuitWithConfirmation(),
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{
|
||||
label: "Command Palette",
|
||||
accelerator: "CmdOrCtrl+K",
|
||||
click: () => onShortcut("toggle-command-palette"),
|
||||
},
|
||||
{
|
||||
label: "Toggle Console",
|
||||
accelerator: "CmdOrCtrl+`",
|
||||
click: () => onShortcut("toggle-console"),
|
||||
},
|
||||
{
|
||||
label: "Refresh Data",
|
||||
accelerator: "CmdOrCtrl+R",
|
||||
click: () => onShortcut("refresh"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "toggleDevTools", visible: isDev },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Run",
|
||||
submenu: [
|
||||
{
|
||||
label: "Check For Updates",
|
||||
click: async () => {
|
||||
await onCheckForUpdates();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
if (process.platform === "darwin") {
|
||||
Menu.setApplicationMenu(menu);
|
||||
} else {
|
||||
// Frameless windows: hide native menu bar but keep keyboard accelerators active
|
||||
Menu.setApplicationMenu(menu);
|
||||
mainWindow?.setMenuBarVisibility(false);
|
||||
mainWindow?.setAutoHideMenuBar(true);
|
||||
}
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
if (appTray) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetsDir = dirname(iconPath);
|
||||
let trayIcon: Electron.NativeImage;
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
// macOS menu bar: use Template images (monochrome black + alpha).
|
||||
// Electron auto-detects the @2x variant when the base name includes "Template".
|
||||
const templatePath = join(assetsDir, "tray-iconTemplate.png");
|
||||
trayIcon = nativeImage.createFromPath(templatePath);
|
||||
if (!trayIcon.isEmpty()) {
|
||||
trayIcon.setTemplateImage(true);
|
||||
}
|
||||
} else {
|
||||
// Windows / Linux: use the color tray icon
|
||||
const trayPath = join(assetsDir, "tray-icon.png");
|
||||
trayIcon = nativeImage.createFromPath(trayPath);
|
||||
}
|
||||
|
||||
// Fallback: resize the full app icon
|
||||
if (trayIcon.isEmpty()) {
|
||||
const fallback = nativeImage.createFromPath(iconPath);
|
||||
if (!fallback.isEmpty()) {
|
||||
trayIcon = fallback.resize({ width: 32, height: 32 });
|
||||
if (process.platform === "darwin") {
|
||||
trayIcon.setTemplateImage(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon);
|
||||
appTray.setToolTip("AgentCompany");
|
||||
appTray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Show AgentCompany",
|
||||
click: () => {
|
||||
void ensureWindow().then((win) => {
|
||||
if (win) {
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Hide AgentCompany",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.hide();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => void requestQuitWithConfirmation(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
appTray.on("click", () => {
|
||||
void ensureWindow().then((win) => {
|
||||
if (!win) return;
|
||||
if (win.isVisible()) {
|
||||
win.hide();
|
||||
return;
|
||||
}
|
||||
win.show();
|
||||
win.focus();
|
||||
});
|
||||
});
|
||||
function initialize() {
|
||||
window.installSecurityGuards();
|
||||
window.registerProtocol();
|
||||
menu.buildMenu();
|
||||
menu.createTray();
|
||||
menu.registerWindowControlIpc();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
appTray?.destroy();
|
||||
appTray = null;
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
installSecurityGuards();
|
||||
registerProtocol();
|
||||
buildMenu();
|
||||
createTray();
|
||||
|
||||
// Window control IPC for custom title bar (Windows/Linux frameless windows)
|
||||
ipcMain.handle("window:minimize", () => { mainWindow?.minimize(); });
|
||||
ipcMain.handle("window:maximize", () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.handle("window:close", () => { mainWindow?.close(); });
|
||||
ipcMain.handle("window:isMaximized", () => mainWindow?.isMaximized() ?? false);
|
||||
menu.destroy();
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
installSecurityGuards,
|
||||
registerProtocol,
|
||||
buildMenu,
|
||||
createTray,
|
||||
createWindow,
|
||||
ensureWindow,
|
||||
getMainWindow,
|
||||
installSecurityGuards: window.installSecurityGuards,
|
||||
registerProtocol: window.registerProtocol,
|
||||
buildMenu: menu.buildMenu,
|
||||
createTray: menu.createTray,
|
||||
createWindow: window.createWindow,
|
||||
ensureWindow: window.ensureWindow,
|
||||
getMainWindow: window.getMainWindow,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user