2026-07-28 02:12:46 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* APS Electron 主进程(对标 Codex 桌面壳)
|
|
|
|
|
|
* - 安装到电脑后用户数据固定:%USERPROFILE%\.aps(同 ~/.codex)
|
|
|
|
|
|
* - 含 skills / sessions / data / cache / tmp / logs / packs
|
2026-08-11 00:54:05 +08:00
|
|
|
|
* - 升级审计与 nonce 续接(round-44 FF ~ round-46 KK):sidecar 一次性审计 token +
|
|
|
|
|
|
* nonce 门禁;sidecar 重启后用新 nonce 对 pending 升级重跑健康检查(见 upgrade-nonce.cjs)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
*/
|
2026-08-11 00:54:05 +08:00
|
|
|
|
const { app, BrowserWindow, Menu, shell, dialog, ipcMain, session } = require('electron');
|
2026-07-28 02:12:46 +08:00
|
|
|
|
const path = require('path');
|
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
|
const crypto = require('crypto');
|
|
|
|
|
|
const os = require('os');
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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');
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
/** 对标 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';
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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('; ');
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
let mainWindow = null;
|
2026-08-11 00:54:05 +08:00
|
|
|
|
let sidecar = null;
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
/** 懒加载升级模块:校验/备份/安装/健康检查/失败自动回滚(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);
|
2026-07-28 02:12:46 +08:00
|
|
|
|
});
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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);
|
2026-07-28 02:12:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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 || '')}`,
|
|
|
|
|
|
],
|
2026-07-28 02:12:46 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-08-11 00:54:05 +08:00
|
|
|
|
win.loadURL(process.env.APS_API_URL);
|
2026-07-28 02:12:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if (url.startsWith('https://')) void shell.openExternal(url);
|
2026-07-28 02:12:46 +08:00
|
|
|
|
return { action: 'deny' };
|
|
|
|
|
|
});
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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);
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
return win;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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;
|
2026-07-28 02:12:46 +08:00
|
|
|
|
ensureApsHome();
|
|
|
|
|
|
// Windows 英文系统下 Electron 默认菜单是英文 File/Edit;
|
|
|
|
|
|
// 中文菜单改由 Web 顶栏(AppMenuBar)呈现,避免双菜单且保证文案为中文。
|
|
|
|
|
|
Menu.setApplicationMenu(null);
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-28 02:12:46 +08:00
|
|
|
|
app.on('activate', () => {
|
|
|
|
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.on('window-all-closed', () => {
|
|
|
|
|
|
if (process.platform !== 'darwin') app.quit();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
app.on('before-quit', () => sidecar?.stop());
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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 || {}),
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('aps:pick-directory', async (event) => {
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if (!isTrustedIpcEvent(event)) throw new Error('Untrusted IPC sender');
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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) => {
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if (!isTrustedIpcEvent(event)) return;
|
2026-07-28 02:12:46 +08:00
|
|
|
|
const wc = event.sender;
|
|
|
|
|
|
if (wc.isDevToolsOpened()) wc.closeDevTools();
|
|
|
|
|
|
else wc.openDevTools({ mode: 'detach' });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function winFrom(event) {
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if (!isTrustedIpcEvent(event)) return null;
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|