aps-agent/apps/desktop/main.cjs

660 lines
22 KiB
JavaScript
Raw Normal View History

/**
* 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(),
});
});