aps-agent/apps/desktop/main.cjs

384 lines
11 KiB
JavaScript
Raw Normal View History

/**
* APS Electron 主进程(对标 Codex 桌面壳)
* - 安装到电脑后用户数据固定:%USERPROFILE%\.aps(同 ~/.codex)
* - 含 skills / sessions / data / cache / tmp / logs / packs
*/
const { app, BrowserWindow, Menu, shell, dialog, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
const { spawn } = require('child_process');
/** 对标 Codex:始终落在用户主目录,安装包不得写到 Program Files */
const USER_APS_HOME = path.join(os.homedir(), '.aps');
const isDev = !app.isPackaged;
// 打包安装态强制用户目录;开发态也默认 ~/.aps(可用环境变量覆盖)
const APS_HOME = (!isDev || !process.env.APS_HOME)
? USER_APS_HOME
: process.env.APS_HOME;
process.env.APS_HOME = APS_HOME;
process.env.APS_MODE = 'desktop';
const UI_URL = process.env.APS_UI_URL || 'http://localhost:5173';
const isMac = process.platform === 'darwin';
let mainWindow = null;
let apiChild = null;
app.setName('工业智核 APS');
function ensureApsHome() {
const layout = ['skills', 'sessions', 'cache', 'tmp', 'logs', 'data', 'packs'];
fs.mkdirSync(APS_HOME, { recursive: true });
for (const name of layout) {
fs.mkdirSync(path.join(APS_HOME, name), { recursive: true });
}
const cfg = path.join(APS_HOME, 'config.json');
if (!fs.existsSync(cfg)) {
fs.writeFileSync(cfg, JSON.stringify({
version: 1,
mode: 'desktop',
layout,
note: '对标 Codex ~/.codex',
}, null, 2), 'utf8');
}
const deviceFile = path.join(APS_HOME, 'device-id');
if (!fs.existsSync(deviceFile)) {
fs.writeFileSync(deviceFile, crypto.randomUUID(), { encoding: 'utf8', mode: 0o600 });
}
}
function getDeviceId() {
ensureApsHome();
return fs.readFileSync(path.join(APS_HOME, 'device-id'), 'utf8').trim();
}
function startBundledApi() {
if (isDev) return;
const repoRoot = path.resolve(__dirname, '..', '..');
const py = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
apiChild = spawn(py, ['-m', 'uvicorn', 'server.main:app', '--host', '127.0.0.1', '--port', '8000'], {
cwd: repoRoot,
env: {
...process.env,
APS_MODE: 'desktop',
APS_HOME,
},
stdio: 'ignore',
});
}
function focusedWebContents() {
const win = BrowserWindow.getFocusedWindow() || mainWindow;
return win?.webContents ?? null;
}
/** 把菜单动作发给渲染进程(Web UI) */
function sendMenuAction(action) {
const wc = focusedWebContents();
if (wc) wc.send('menu:action', action);
}
function openApsHome() {
ensureApsHome();
shell.openPath(APS_HOME);
}
function showAbout() {
dialog.showMessageBox(BrowserWindow.getFocusedWindow() || mainWindow, {
type: 'info',
title: '关于工业智核 APS',
message: '工业智核 APS',
detail: [
'计划排产智能体 · 桌面端',
`用户目录:${APS_HOME}`,
'对标 Codex:skills / sessions / data 本地落盘',
].join('\n'),
buttons: ['确定'],
});
}
/** Codex 风格中文菜单栏 */
function buildAppMenu() {
const template = [
...(isMac ? [{
label: app.name,
submenu: [
{ label: '关于工业智核 APS', click: showAbout },
{ type: 'separator' },
{ label: '服务', role: 'services' },
{ type: 'separator' },
{ label: '隐藏', role: 'hide' },
{ label: '隐藏其他', role: 'hideOthers' },
{ label: '显示全部', role: 'unhide' },
{ type: 'separator' },
{ label: '退出', role: 'quit' },
],
}] : []),
{
label: '文件',
submenu: [
{
label: '新建窗口',
accelerator: 'CmdOrCtrl+Shift+N',
click: () => createWindow(),
},
{
label: '新建话题',
accelerator: 'CmdOrCtrl+N',
click: () => sendMenuAction('new-chat'),
},
{ type: 'separator' },
{
label: '打开用户目录…',
accelerator: 'CmdOrCtrl+O',
click: openApsHome,
},
{
label: '打开技能目录…',
click: () => {
ensureApsHome();
shell.openPath(path.join(APS_HOME, 'skills'));
},
},
{ type: 'separator' },
{
label: '关闭窗口',
accelerator: 'CmdOrCtrl+W',
role: 'close',
},
...(!isMac ? [
{ type: 'separator' },
{ label: '退出', accelerator: 'Alt+F4', role: 'quit' },
] : []),
],
},
{
label: '编辑',
submenu: [
{ label: '撤销', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
{ label: '重做', accelerator: 'Shift+CmdOrCtrl+Z', role: 'redo' },
{ type: 'separator' },
{ label: '剪切', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: '复制', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: '粘贴', accelerator: 'CmdOrCtrl+V', role: 'paste' },
{ label: '全选', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
],
},
{
label: '视图',
submenu: [
{ label: '重新加载', accelerator: 'CmdOrCtrl+R', role: 'reload' },
{ label: '强制重新加载', accelerator: 'CmdOrCtrl+Shift+R', role: 'forceReload' },
{ label: '开发者工具', accelerator: isMac ? 'Alt+Command+I' : 'Ctrl+Shift+I', role: 'toggleDevTools' },
{ type: 'separator' },
{ label: '实际大小', accelerator: 'CmdOrCtrl+0', role: 'resetZoom' },
{ label: '放大', accelerator: 'CmdOrCtrl+Plus', role: 'zoomIn' },
{ label: '缩小', accelerator: 'CmdOrCtrl+-', role: 'zoomOut' },
{ type: 'separator' },
{ label: '全屏', accelerator: isMac ? 'Ctrl+Command+F' : 'F11', role: 'togglefullscreen' },
{ type: 'separator' },
{
label: '命令面板',
accelerator: 'CmdOrCtrl+K',
click: () => sendMenuAction('command-palette'),
},
{
label: '打开设置',
accelerator: 'CmdOrCtrl+,',
click: () => sendMenuAction('open-settings'),
},
],
},
{
label: '窗口',
submenu: [
{ label: '最小化', role: 'minimize' },
{ label: '缩放', role: 'zoom' },
...(isMac ? [
{ type: 'separator' },
{ label: '前置全部窗口', role: 'front' },
] : [
{ type: 'separator' },
{ label: '关闭', role: 'close' },
]),
],
},
{
label: '帮助',
submenu: [
{
label: '打开用户目录 ~/.aps',
click: openApsHome,
},
{
label: '系统路径说明',
click: () => sendMenuAction('open-paths'),
},
{ type: 'separator' },
{
label: '关于工业智核 APS',
click: showAbout,
},
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
function createWindow() {
const win = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1100,
minHeight: 700,
title: 'APS 智能排产工作台',
show: false,
// 自绘顶栏 + 窗口按钮(最小化 / 最大化·还原 / 关闭)
...(process.platform === 'win32' ? {
frame: false,
titleBarStyle: 'hidden',
autoHideMenuBar: true,
} : {}),
webPreferences: {
preload: path.resolve(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
if (!mainWindow) mainWindow = win;
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
});
win.once('ready-to-show', () => win.show());
const pushState = () => {
if (win.isDestroyed()) return;
win.webContents.send('aps:window-state', {
maximized: win.isMaximized(),
fullScreen: win.isFullScreen(),
});
};
win.on('maximize', pushState);
win.on('unmaximize', pushState);
win.on('enter-full-screen', pushState);
win.on('leave-full-screen', pushState);
win.webContents.on('did-finish-load', pushState);
if (isDev) {
win.loadURL(UI_URL);
// 仅首窗默认开 DevTools,避免多窗刷屏
if (BrowserWindow.getAllWindows().length === 1) {
win.webContents.openDevTools({ mode: 'detach' });
}
} else {
const distIndex = path.join(__dirname, 'web', 'dist', 'index.html');
if (fs.existsSync(distIndex)) {
win.loadFile(distIndex);
} else {
win.loadURL('http://127.0.0.1:8000/');
}
}
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
return win;
}
app.whenReady().then(() => {
ensureApsHome();
// Windows 英文系统下 Electron 默认菜单是英文 File/Edit;
// 中文菜单改由 Web 顶栏(AppMenuBar)呈现,避免双菜单且保证文案为中文。
Menu.setApplicationMenu(null);
startBundledApi();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (apiChild) {
try { apiChild.kill(); } catch { /* ignore */ }
}
if (process.platform !== 'darwin') app.quit();
});
ipcMain.handle('aps:get-home', () => ({
mode: 'desktop',
home: APS_HOME,
skills: path.join(APS_HOME, 'skills'),
sessions: path.join(APS_HOME, 'sessions'),
}));
ipcMain.handle('aps:get-device-id', () => getDeviceId());
ipcMain.handle('aps:pick-directory', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(win ?? undefined, {
title: '选择工程目录',
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || !result.filePaths?.[0]) return null;
return result.filePaths[0];
});
ipcMain.on('aps:toggle-devtools', (event) => {
const wc = event.sender;
if (wc.isDevToolsOpened()) wc.closeDevTools();
else wc.openDevTools({ mode: 'detach' });
});
function winFrom(event) {
return BrowserWindow.fromWebContents(event.sender);
}
ipcMain.on('aps:toggle-fullscreen', (event) => {
const win = winFrom(event);
if (!win) return;
win.setFullScreen(!win.isFullScreen());
});
ipcMain.on('aps:leave-fullscreen', (event) => {
const win = winFrom(event);
if (!win) return;
// Esc 只退真正全屏,避免误伤「仅最大化」
if (win.isFullScreen()) win.setFullScreen(false);
});
ipcMain.on('aps:window-minimize', (event) => {
winFrom(event)?.minimize();
});
ipcMain.on('aps:window-maximize', (event) => {
const win = winFrom(event);
if (!win) return;
// 「还原」同时覆盖:全屏退出 + 最大化还原
if (win.isFullScreen()) {
win.setFullScreen(false);
return;
}
if (win.isMaximized()) win.unmaximize();
else win.maximize();
});
ipcMain.on('aps:window-close', (event) => {
winFrom(event)?.close();
});
ipcMain.on('aps:window-state-request', (event) => {
const win = winFrom(event);
if (!win) return;
event.sender.send('aps:window-state', {
maximized: win.isMaximized(),
fullScreen: win.isFullScreen(),
});
});