aps-agent/apps/desktop/main.cjs

660 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* APS Electron 主进程(对标 Codex 桌面壳)
* - 安装到电脑后用户数据固定:%USERPROFILE%\.aps(同 ~/.codex)
* - 含 skills / sessions / data / cache / tmp / logs / packs
* - 升级审计与 nonce 续接(round-44 FF ~ round-46 KK):sidecar 一次性审计 token +
* nonce 门禁;sidecar 重启后用新 nonce 对 pending 升级重跑健康检查(见 upgrade-nonce.cjs)
*/
const { app, BrowserWindow, Menu, shell, dialog, ipcMain, session } = require('electron');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
const { SidecarManager } = require('./sidecar.cjs');
const { Updater, probeHealth } = require('./updater.cjs');
const { createUpgradeNonceCoordinator, UPGRADE_NONCE_EVENT_VERIFIED, UPGRADE_NONCE_EVENT_FAILED } = require('./upgrade-nonce.cjs');
/** 对标 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';
const PACKAGED_CSP = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' ws: wss:",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
].join('; ');
let mainWindow = null;
let sidecar = 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();
}
/** 懒加载升级模块:校验/备份/安装/健康检查/失败自动回滚(audit 本地日志 + 可选上报) */
function createUpdater(options = {}) {
return new Updater({
apsHome: APS_HOME,
logger: console,
...options,
});
}
/** 升级 nonce 续接协调器(round-46 KK,矩阵 103 最后本地项):
* sidecar 重启后把新 nonce 交给待验证升级,重跑 probeHealth(升级后健康检查 + 版本比对),
* 成功上报 audit(带新 nonce)、失败本地 JSONL 留痕。纯逻辑见 upgrade-nonce.cjs,
* 此处仅做 Electron 接线:apply-update 前后维护 pending + restarted 事件触发。 */
const upgradeNonceCoordinator = createUpgradeNonceCoordinator({
statePath: path.join(APS_HOME, 'upgrade-state.json'), // round-48:pending 跨进程持久化,应用重启可续接
verifyHealth: ({ origin, nonce, expectedVersion }) =>
probeHealth({ base: origin, expectedVersion, sidecarNonce: nonce }),
onComplete: async ({ pending, health, nonce }) => {
// 续接成功:用新 nonce 建 updater 上报 audit(本地 JSONL 主通道 + 可选 /api/gov/audit/events)
const updater = createUpdater({
...(pending.audit || {}),
sidecarNonce: nonce,
});
await updater.writeAudit({
event: UPGRADE_NONCE_EVENT_VERIFIED,
payload: {
fromVersion: pending.currentVersion || null,
toVersion: pending.targetVersion || null,
expectedVersion: pending.expectedVersion || null,
version: health?.version ?? null,
nonce,
verifiedVia: 'sidecar-restart',
},
});
},
onFailure: async ({ pending, error }) => {
try {
const updater = createUpdater({
...(pending.audit || {}),
sidecarNonce: pending.nonce || null,
});
await updater.writeAudit({
event: UPGRADE_NONCE_EVENT_FAILED,
result: 'FAILED',
payload: {
fromVersion: pending.currentVersion || null,
toVersion: pending.targetVersion || null,
expectedVersion: pending.expectedVersion || null,
nonce: pending.nonce || null,
error: { code: error.code, message: error.message },
},
});
} catch (writeError) {
console.error('[upgrade-nonce] 失败留痕写入异常:', writeError);
}
console.error('[upgrade-nonce] 升级后健康检查失败(新 nonce 续接未通过):', error.message);
},
logger: console,
});
/** 升级审计通道(round-44 FF):优先使用渲染进程显式传入的 audit 凭据(IPC 显式覆盖);
* 否则回落到 sidecar 一次性审计 token(仅限本机、进程内,不外泄,不写盘/不进子进程 env)。
* 返回 Updater 构造参数片段或 null(本地 JSONL 为默认通道)。 */
function resolveAuditOptions(requested = {}) {
if (requested.apiUrl && requested.token) {
return { auditApiUrl: requested.apiUrl, auditToken: requested.token };
}
const origin = sidecar?.origin || process.env.APS_API_URL || null;
const token = sidecar?.auditToken || null;
if (!origin || !token) return null;
return {
auditApiUrl: origin,
auditToken: token,
...(sidecar?.nonce ? { sidecarNonce: sidecar.nonce } : {}),
};
}
async function startBundledApi() {
if (isDev) return process.env.APS_API_URL || 'http://127.0.0.1:8000';
sidecar = new SidecarManager({
resourcesPath: process.resourcesPath,
apsHome: APS_HOME,
appVersion: app.getVersion(),
});
installSidecarRequestGuard();
sidecar.on('restarting', () => suspendPackagedWindows());
sidecar.on('restarted', ({ origin }) => {
process.env.APS_API_URL = origin;
reloadPackagedWindows(origin);
// round-46 KK:若有待验证升级(pendingUpgrade status='verifying'),用新 sidecar nonce
// 重跑升级后健康检查(版本比对):成功 → 上报 audit(带新 nonce);失败 → 本地 JSONL 留痕。
void upgradeNonceCoordinator.onSidecarRestarted({
origin,
nonce: sidecar.nonce,
}).catch((error) => {
console.error('[upgrade-nonce] restarted 续接异常:', error);
});
});
sidecar.on('fatal', (error) => {
dialog.showErrorBox(
'APS local service stopped',
`${error.message}\n\nLog: ${path.join(APS_HOME, 'logs', 'sidecar.log')}`,
);
app.quit();
});
const apiUrl = await sidecar.start();
process.env.APS_API_URL = apiUrl;
return apiUrl;
}
function installSidecarRequestGuard() {
const desktopSession = session.defaultSession;
desktopSession.webRequest.onBeforeSendHeaders((details, callback) => {
const origin = sidecar?.origin;
if (origin && details.url.startsWith(`${origin}/`) && sidecar.nonce) {
details.requestHeaders['x-aps-sidecar-nonce'] = sidecar.nonce;
}
callback({ requestHeaders: details.requestHeaders });
});
desktopSession.webRequest.onHeadersReceived((details, callback) => {
const origin = sidecar?.origin;
if (!origin || !details.url.startsWith(`${origin}/`)) {
callback({ responseHeaders: details.responseHeaders });
return;
}
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [PACKAGED_CSP],
},
});
});
desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false);
});
desktopSession.setPermissionCheckHandler(() => false);
}
function suspendPackagedWindows() {
for (const win of BrowserWindow.getAllWindows()) {
win.hide();
win.webContents.stop();
void win.loadURL('about:blank').catch(() => {});
}
}
function reloadPackagedWindows(origin) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) win.show();
});
void win.loadURL(origin).catch((error) => {
dialog.showErrorBox('APS desktop UI failed to reload', String(error));
app.quit();
});
}
}
function isTrustedRendererUrl(url) {
if (url === 'about:blank') return true;
const trustedOrigin = isDev ? UI_URL : sidecar?.origin;
if (!trustedOrigin) return false;
try {
return new URL(url).origin === new URL(trustedOrigin).origin;
} catch {
return false;
}
}
function isTrustedIpcEvent(event) {
const url = event.senderFrame?.url || event.sender?.getURL?.() || '';
return url !== 'about:blank' && isTrustedRendererUrl(url);
}
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: true,
additionalArguments: [
`--aps-home=${encodeURIComponent(APS_HOME)}`,
`--aps-skills-dir=${encodeURIComponent(path.join(APS_HOME, 'skills'))}`,
`--aps-sessions-dir=${encodeURIComponent(path.join(APS_HOME, 'sessions'))}`,
`--aps-api-url=${encodeURIComponent(process.env.APS_API_URL || '')}`,
],
},
});
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 {
win.loadURL(process.env.APS_API_URL);
}
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https://')) void shell.openExternal(url);
return { action: 'deny' };
});
const guardNavigation = (event, url) => {
if (isTrustedRendererUrl(url)) return;
event.preventDefault();
if (url.startsWith('https://')) void shell.openExternal(url);
};
win.webContents.on('will-navigate', guardNavigation);
win.webContents.on('will-redirect', guardNavigation);
return win;
}
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) app.quit();
app.on('second-instance', () => {
const win = mainWindow || BrowserWindow.getAllWindows()[0];
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
});
app.whenReady().then(async () => {
if (!hasSingleInstanceLock) return;
ensureApsHome();
// Windows 英文系统下 Electron 默认菜单是英文 File/Edit;
// 中文菜单改由 Web 顶栏(AppMenuBar)呈现,避免双菜单且保证文案为中文。
Menu.setApplicationMenu(null);
try {
await startBundledApi();
createWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
dialog.showErrorBox(
'APS local service failed to start',
`${message}\n\nLog: ${path.join(APS_HOME, 'logs', 'sidecar.log')}`,
);
app.quit();
return;
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => sidecar?.stop());
ipcMain.handle('aps:get-home', (event) => {
if (!isTrustedIpcEvent(event)) throw new Error('Untrusted IPC sender');
return {
mode: 'desktop',
home: APS_HOME,
skills: path.join(APS_HOME, 'skills'),
sessions: path.join(APS_HOME, 'sessions'),
};
});
ipcMain.handle('aps:get-device-id', (event) => {
if (!isTrustedIpcEvent(event)) throw new Error('Untrusted IPC sender');
return getDeviceId();
});
const UPGRADE_IPC_GUARD = 'Untrusted IPC sender';
/** 检查离线升级:manifestPath + packagePath(U 盘/介质)→ {updateAvailable, currentVersion, targetVersion, verified} */
ipcMain.handle('aps:check-for-update', async (event, options) => {
if (!isTrustedIpcEvent(event)) throw new Error(UPGRADE_IPC_GUARD);
const currentVersion = app.getVersion();
const updater = createUpdater();
return updater.checkForUpdate({ currentVersion, ...(options || {}) });
});
/** 应用离线升级:校验 → 备份 → 安装 → 健康检查 → 失败自动回滚。
* round-46 KK:apply-update 前登记 pendingUpgrade(installing),sidecar 重启后用新 nonce
* 续接最终健康检查(restarted 事件驱动,见 upgrade-nonce.cjs);同时把当前 sidecar nonce
* 显式传给 applyUpdate 的安装后健康检查,避免初检带着过期 nonce。 */
ipcMain.handle('aps:apply-update', async (event, options) => {
if (!isTrustedIpcEvent(event)) throw new Error(UPGRADE_IPC_GUARD);
const audit = resolveAuditOptions((options || {}).audit) || {};
const updater = createUpdater(audit);
const currentVersion = app.getVersion();
// round-46 KK:sidecar 存在(打包态)才登记 pending——nonce 续接依赖 sidecar 重启事件;
// dev 态(无 sidecar)由 applyUpdate 自带的健康检查负责即可。
const pending = sidecar ? upgradeNonceCoordinator.setPending({
id: crypto.randomUUID(),
currentVersion,
targetVersion: (options || {}).expectedVersion || null,
expectedVersion: (options || {}).expectedVersion || null,
healthBase: (options || {}).healthBase || sidecar?.origin || process.env.APS_API_URL || null,
audit,
startedAt: new Date().toISOString(),
}) : null;
try {
const report = await updater.applyUpdate({
currentVersion,
installDir: path.dirname(process.execPath),
...(options || {}),
...(sidecar?.nonce ? { sidecarNonce: sidecar.nonce } : {}),
});
// applyUpdate 自带健康检查为初步校验;保留 pending 等 sidecar 重启(新 nonce)做最终校验。
// 无 sidecar(dev)时 deferFinalVerify=false 直接完成。
if (pending) {
void upgradeNonceCoordinator.markInstalled({ id: pending.id, report, deferFinalVerify: Boolean(sidecar) })
.catch((error) => console.error('[upgrade-nonce] markInstalled 异常:', error));
}
return report;
} catch (error) {
if (pending) upgradeNonceCoordinator.markFailed({ id: pending.id, error });
throw error;
}
});
/** 手动回滚:恢复备份 + 审计(也用于升级失败后的恢复操作) */
ipcMain.handle('aps:rollback', async (event, options) => {
if (!isTrustedIpcEvent(event)) throw new Error(UPGRADE_IPC_GUARD);
const updater = createUpdater({
...(resolveAuditOptions((options || {}).audit) || {}),
});
return updater.rollback({
installDir: path.dirname(process.execPath),
...(options || {}),
});
});
ipcMain.handle('aps:pick-directory', async (event) => {
if (!isTrustedIpcEvent(event)) throw new Error('Untrusted IPC sender');
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) => {
if (!isTrustedIpcEvent(event)) return;
const wc = event.sender;
if (wc.isDevToolsOpened()) wc.closeDevTools();
else wc.openDevTools({ mode: 'detach' });
});
function winFrom(event) {
if (!isTrustedIpcEvent(event)) return null;
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(),
});
});