dsh-oc-desktop 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/icon-app.png +0 -0
- package/assets/icon-dark.png +0 -0
- package/assets/icon-light.png +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/assets/splash-whale.png +0 -0
- package/assets/tray.ico +0 -0
- package/assets/tray.png +0 -0
- package/assets/whale.svg +8 -0
- package/bin/dsh-desktop.cjs +52 -0
- package/launcher/apply-update.js +69 -0
- package/launcher/events.js +118 -0
- package/launcher/main.js +800 -0
- package/launcher/preload.js +148 -0
- package/launcher/session-window.js +22 -0
- package/launcher/splash.html +27 -0
- package/launcher/updater.js +188 -0
- package/package.json +19 -0
- package/scripts/test.mjs +15 -0
package/launcher/main.js
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// dsh-desktop 主进程
|
|
3
|
+
// 职责:单实例锁 / spawn 本地引擎(dsh web)/ 窗口与自绘标题栏 / 托盘 / 通知 /
|
|
4
|
+
// 全局快捷键 / 开机自启 / 窗口状态记忆 / 崩溃自愈
|
|
5
|
+
const { app, BrowserWindow, Tray, Menu, Notification, globalShortcut, ipcMain, shell, nativeImage } = require('electron');
|
|
6
|
+
const { spawn, execFile } = require('node:child_process');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const { connectEvents } = require('./events');
|
|
11
|
+
const { applyUpdate } = require('./apply-update');
|
|
12
|
+
const { checkForUpdates, fetchJson, diffLocal, downloadUpdate } = require('./updater');
|
|
13
|
+
const { parseCurrentSession, SESSION_ID_RE } = require('./session-window');
|
|
14
|
+
|
|
15
|
+
// ---------- 路径常量 ----------
|
|
16
|
+
// 本文件从仓库 src/main.js 迁至 plugins/dsh-desktop/launcher/main.js(插件拆分)。
|
|
17
|
+
// dsh-oc-desktop 由 electron . 运行(非打包为独立可执行),更新/apply 目录基于包自身根。
|
|
18
|
+
const PACKAGE_ROOT = path.join(__dirname, '..'); // plugins/dsh-desktop(包根,自带 assets)
|
|
19
|
+
|
|
20
|
+
app.setName('dsh-desktop');
|
|
21
|
+
// Windows 原生通知需要 AppUserModelID(与 electron-builder 的 appId 一致),否则打包后通知不显示
|
|
22
|
+
app.setAppUserModelId('dev.dsh.desktop');
|
|
23
|
+
|
|
24
|
+
// ---------- 更新应用模式 ----------
|
|
25
|
+
// 主进程以 --dsh-apply-update 重启:本进程只负责把 staging 文件树替换到位,
|
|
26
|
+
// exe 本体与最终重启交给 .dsh-apply.cmd(脱离进程执行,绕开 exe 文件锁)。
|
|
27
|
+
if (process.argv.includes('--dsh-apply-update')) {
|
|
28
|
+
const appRoot = PACKAGE_ROOT;
|
|
29
|
+
const res = applyUpdate(appRoot, path.join(appRoot, '.dsh-update-staging'));
|
|
30
|
+
if (res.ok && res.cmdPath) {
|
|
31
|
+
try { spawn('cmd.exe', ['/c', res.cmdPath], { detached: true, stdio: 'ignore', windowsHide: true }).unref(); } catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ASSETS = path.join(PACKAGE_ROOT, 'assets');
|
|
37
|
+
const userDataDir = app.getPath('userData'); // %APPDATA%\dsh-desktop
|
|
38
|
+
const logDir = path.join(userDataDir, 'logs');
|
|
39
|
+
const mainLogPath = path.join(logDir, 'main.log');
|
|
40
|
+
const harnessLogPath = path.join(logDir, 'harness.log');
|
|
41
|
+
const settingsPath = path.join(userDataDir, 'settings.json');
|
|
42
|
+
const statePath = path.join(userDataDir, 'window-state.json');
|
|
43
|
+
|
|
44
|
+
// ---------- 日志 ----------
|
|
45
|
+
function writeLog(file, level, msg) {
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
48
|
+
fs.appendFileSync(file, `${new Date().toISOString()} [${level}] ${msg}\n`);
|
|
49
|
+
} catch { /* 日志失败不致命 */ }
|
|
50
|
+
}
|
|
51
|
+
const log = (level, msg) => {
|
|
52
|
+
writeLog(mainLogPath, level, msg);
|
|
53
|
+
if (level !== 'harness') console.log(`[${level}] ${msg}`);
|
|
54
|
+
};
|
|
55
|
+
const hlog = (msg) => writeLog(harnessLogPath, 'harness', msg);
|
|
56
|
+
|
|
57
|
+
// ---------- 配置 ----------
|
|
58
|
+
const DEFAULT_SETTINGS = {
|
|
59
|
+
launcher: null, // 自定义启动命令数组,如 ["node", "C:\\...\\bin.js", "web"]
|
|
60
|
+
autostart: false, // 开机自启
|
|
61
|
+
customTitleBar: true, // 自绘标题栏(壳自有能力,不依赖 SPA,DSH 更新不影响)
|
|
62
|
+
shortcut: 'Control+Alt+D', // 全局显示/隐藏快捷键
|
|
63
|
+
startupTimeoutMs: 90000, // 引擎启动超时
|
|
64
|
+
dataHome: null, // 独立数据根(null = %APPDATA%\dsh-desktop\home,即独立的 DSH_HOME)
|
|
65
|
+
updateUrl: null, // 更新服务器根地址(version.json 所在目录);null = 未配置,托盘「检查更新」会提示
|
|
66
|
+
};
|
|
67
|
+
function loadJson(file, fallback) {
|
|
68
|
+
try { return { ...fallback, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; }
|
|
69
|
+
catch { return fallback; }
|
|
70
|
+
}
|
|
71
|
+
function saveJson(file, obj) {
|
|
72
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(obj, null, 2)); } catch { /* ignore */ }
|
|
73
|
+
}
|
|
74
|
+
let settings = loadJson(settingsPath, DEFAULT_SETTINGS);
|
|
75
|
+
|
|
76
|
+
// 命令行 --no-open(桌面快捷方式传入):引擎启动时不自动打开默认浏览器,
|
|
77
|
+
// 桌面壳只需原生窗口;命令行启动(npx dsh-desktop / npm start)不传则保持默认弹浏览器。
|
|
78
|
+
const NO_OPEN = process.argv.includes('--no-open');
|
|
79
|
+
|
|
80
|
+
// 桌面应用使用独立的数据根(DSH_HOME):会话/存储/凭据/设置与浏览器实例完全隔离
|
|
81
|
+
// 桌面端与 web 端合并:共享数据根(默认 ~/.dsh,与浏览器版同一份数据)。
|
|
82
|
+
// settings.dataHome 可覆盖;引擎复用 127.0.0.1:3080(web 端端口)——桌面端只是另一个入口。
|
|
83
|
+
const dataHome = settings.dataHome || path.join(os.homedir(), '.dsh');
|
|
84
|
+
|
|
85
|
+
// ---------- 独立数据根引导 ----------
|
|
86
|
+
// profile 的 node_modules 是 junction 层(包实体在 npx 缓存/源安装树里),
|
|
87
|
+
// 这里按原样重建 junction 指向同一实体(只读复用,零下载),数据目录则是全新的。
|
|
88
|
+
function replicateEntry(src, dst) {
|
|
89
|
+
try {
|
|
90
|
+
const st = fs.lstatSync(src);
|
|
91
|
+
if (st.isSymbolicLink()) {
|
|
92
|
+
const target = fs.readlinkSync(src);
|
|
93
|
+
fs.symlinkSync(target, dst, 'junction');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (st.isDirectory()) {
|
|
97
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
98
|
+
for (const name of fs.readdirSync(src)) replicateEntry(path.join(src, name), path.join(dst, name));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (st.isFile()) fs.copyFileSync(src, dst);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
log('warn', `replicate ${src} failed: ${e.message}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 自动安装源 dsh(npx 引导一次):本机没有任何 dsh 安装时,拉取并初始化
|
|
107
|
+
// 源 profile。与 scripts/ensure-dsh.js 逻辑一致(打包后无法 require 外部脚本,
|
|
108
|
+
// 故内联一份)。判定"就绪"= 引擎打印 URL 行(profile 初始化 + profiles\node_modules
|
|
109
|
+
// 符号层自愈都在打印之前完成),随后杀掉引导进程。
|
|
110
|
+
const AUTO_INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
111
|
+
function autoBootstrapSource() {
|
|
112
|
+
const srcHome = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
113
|
+
const srcBin = path.join(srcHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
log('info', 'source dsh missing — auto-installing via npx (first run, needs network ~100MB)');
|
|
116
|
+
const line = ['npx', '--yes', '@deepseek-ai/dsh', 'web', '--port', '0', ...(NO_OPEN ? ['--no-open'] : [])].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
|
|
117
|
+
const proc = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', line], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
118
|
+
let buf = '';
|
|
119
|
+
let settled = false;
|
|
120
|
+
const finish = (ok) => { if (settled) return; settled = true; clearTimeout(timer); resolve(ok); };
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
killProcTree(proc);
|
|
123
|
+
// URL 没等到,但 profile 可能已初始化(自愈先行);按实际结果判定
|
|
124
|
+
finish(fs.existsSync(srcBin));
|
|
125
|
+
}, AUTO_INSTALL_TIMEOUT_MS);
|
|
126
|
+
proc.stdout.on('data', (d) => {
|
|
127
|
+
buf += String(d);
|
|
128
|
+
let nl;
|
|
129
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
130
|
+
const l = buf.slice(0, nl).trim();
|
|
131
|
+
buf = buf.slice(nl + 1);
|
|
132
|
+
if (l) hlog(l);
|
|
133
|
+
if (/dsh web:\s*https?:\/\/127\.0\.0\.1:\d+/.test(l)) { log('info', 'auto-install: dsh booted'); killProcTree(proc); finish(true); return; }
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
proc.stderr.on('data', (d) => hlog(String(d).trimEnd()));
|
|
137
|
+
proc.on('error', () => finish(false));
|
|
138
|
+
proc.on('exit', () => finish(fs.existsSync(srcBin)));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function killProcTree(proc) {
|
|
142
|
+
try { proc.kill(); } catch { /* ignore */ }
|
|
143
|
+
if (process.platform === 'win32' && proc.pid) {
|
|
144
|
+
execFile('taskkill', ['/pid', String(proc.pid), '/t', '/f'], () => { /* ignore */ });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function ensureDataHome() {
|
|
148
|
+
// 合并后:数据根 = 共享 ~/.dsh(与浏览器版一致)。引擎由 resolveLauncher 启动;
|
|
149
|
+
// 数据根内若未引导过 dsh 则 npx 引导一次。
|
|
150
|
+
fs.mkdirSync(dataHome, { recursive: true });
|
|
151
|
+
const bin = path.join(dataHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
152
|
+
if (fs.existsSync(bin)) return true;
|
|
153
|
+
log('warn', `dsh not bootstrapped at ${dataHome}; auto-installing via npx`);
|
|
154
|
+
const installed = await autoBootstrapSource();
|
|
155
|
+
if (!installed) {
|
|
156
|
+
log('error', `bootstrap failed: dsh missing at ${dataHome} and auto-install failed`);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------- 状态 ----------
|
|
163
|
+
let mainWin = null, splash = null, tray = null;
|
|
164
|
+
let child = null, harnessUrl = null, quitting = false;
|
|
165
|
+
let restartTimer = null, startTimer = null, stateTimer = null;
|
|
166
|
+
let stdoutBuf = '', restartCount = 0;
|
|
167
|
+
const sessionWindows = new Set();
|
|
168
|
+
const openSessionArg = process.argv.find((a) => a.startsWith('--open-session-window='));
|
|
169
|
+
let pendingOpenSession = openSessionArg ? openSessionArg.slice('--open-session-window='.length) : null;
|
|
170
|
+
if (pendingOpenSession && !SESSION_ID_RE.test(pendingOpenSession)) pendingOpenSession = null;
|
|
171
|
+
|
|
172
|
+
// ---------- 启动器解析 ----------
|
|
173
|
+
// 优先级:settings.launcher > 共享数据根内已装 dsh > npx 兜底。
|
|
174
|
+
// 引擎始终以 DSH_HOME=<dataHome> 运行,数据与会话与浏览器实例完全隔离。
|
|
175
|
+
function resolveLauncher() {
|
|
176
|
+
// --no-open 仅追加到壳管理的启动路径(settings.launcher 是用户显式配置,不越权改动)
|
|
177
|
+
const noOpen = NO_OPEN ? ['--no-open'] : [];
|
|
178
|
+
if (Array.isArray(settings.launcher) && settings.launcher.length >= 1) {
|
|
179
|
+
return { command: settings.launcher[0], args: settings.launcher.slice(1) };
|
|
180
|
+
}
|
|
181
|
+
const bin = path.join(dataHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
182
|
+
if (fs.existsSync(bin)) return { command: 'node', args: [bin, 'web', ...noOpen] };
|
|
183
|
+
return { command: 'npx', args: ['--yes', '@deepseek-ai/dsh', 'web', ...noOpen] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------- 引擎子进程 ----------
|
|
187
|
+
// 合并模式:优先连接已运行的 web 端引擎(127.0.0.1:3080)——同一引擎、同一数据;
|
|
188
|
+
// 没有则自己启动(固定 3080 端口,天然防止双实例写同一数据根)。
|
|
189
|
+
const WEB_PORT = 3080;
|
|
190
|
+
function probePort(port, timeoutMs, cb) {
|
|
191
|
+
httpGet(`http://127.0.0.1:${port}/`, timeoutMs)
|
|
192
|
+
.then(() => { log('info', `probe ${port}: alive`); cb(true); })
|
|
193
|
+
.catch((e) => { log('warn', `probe ${port}: not responding (${e && e.message ? e.message : e})`); cb(false); });
|
|
194
|
+
}
|
|
195
|
+
function httpGet(url, timeoutMs) {
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
const req = require('node:http').get(url, (res) => { res.resume(); resolve(res.statusCode); });
|
|
198
|
+
req.setTimeout(timeoutMs || 1500, () => { req.destroy(new Error('timeout')); });
|
|
199
|
+
req.on('error', reject);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function startHarness() {
|
|
203
|
+
if (child) return;
|
|
204
|
+
// 1. 检测 web 端引擎是否已在跑
|
|
205
|
+
probePort(WEB_PORT, 1500, (alive) => {
|
|
206
|
+
if (alive) {
|
|
207
|
+
log('info', `web engine already running at 127.0.0.1:${WEB_PORT} — connecting as another entry`);
|
|
208
|
+
onHarnessReady(`http://127.0.0.1:${WEB_PORT}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
spawnEngine();
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
function spawnEngine() {
|
|
215
|
+
const { command, args } = resolveLauncher();
|
|
216
|
+
const fullArgs = [...args, '--host', '127.0.0.1', '--port', String(WEB_PORT)];
|
|
217
|
+
log('info', `spawning harness: ${command} ${fullArgs.join(' ')} (DSH_HOME=${dataHome})`);
|
|
218
|
+
let proc;
|
|
219
|
+
const spawnEnv = { ...process.env, DSH_HOME: dataHome };
|
|
220
|
+
if (process.platform === 'win32' && command === 'npx') {
|
|
221
|
+
// npx 是 .cmd,须经 cmd /c 执行;windowsHide 隐藏命令窗口
|
|
222
|
+
const line = ['npx', ...fullArgs].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
|
|
223
|
+
proc = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', line], { windowsHide: true, env: spawnEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
224
|
+
} else {
|
|
225
|
+
proc = spawn(command, fullArgs, { windowsHide: true, env: spawnEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
226
|
+
}
|
|
227
|
+
child = proc;
|
|
228
|
+
proc.stdout.on('data', (d) => onHarnessStdout(String(d)));
|
|
229
|
+
proc.stderr.on('data', (d) => hlog(String(d).trimEnd()));
|
|
230
|
+
proc.on('error', (err) => {
|
|
231
|
+
log('error', `harness spawn error: ${err.message}`);
|
|
232
|
+
showError(`无法启动本地引擎: ${err.message}`);
|
|
233
|
+
child = null;
|
|
234
|
+
});
|
|
235
|
+
proc.on('exit', (code, sig) => onHarnessExit(code, sig));
|
|
236
|
+
clearTimeout(startTimer);
|
|
237
|
+
startTimer = setTimeout(() => {
|
|
238
|
+
if (!harnessUrl) {
|
|
239
|
+
log('warn', 'harness startup timeout');
|
|
240
|
+
showError('本地引擎启动超时。请查看日志目录 logs\\ 后点击「重试」。');
|
|
241
|
+
}
|
|
242
|
+
}, settings.startupTimeoutMs);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 按行缓冲解析 "dsh web: http://127.0.0.1:PORT"
|
|
246
|
+
function onHarnessStdout(chunk) {
|
|
247
|
+
stdoutBuf += chunk;
|
|
248
|
+
let nl;
|
|
249
|
+
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
|
|
250
|
+
const line = stdoutBuf.slice(0, nl).trim();
|
|
251
|
+
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
252
|
+
if (line) hlog(line);
|
|
253
|
+
const m = line.match(/dsh web:\s*(https?:\/\/127\.0\.0\.1:\d+)/);
|
|
254
|
+
if (m) onHarnessReady(m[1]);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function onHarnessReady(url) {
|
|
259
|
+
if (harnessUrl === url) return;
|
|
260
|
+
harnessUrl = url;
|
|
261
|
+
clearTimeout(startTimer);
|
|
262
|
+
log('info', `harness ready at ${url}`);
|
|
263
|
+
if (splash && !splash.isDestroyed()) splash.close();
|
|
264
|
+
if (mainWin && !mainWin.isDestroyed()) {
|
|
265
|
+
mainWin.loadURL(desktopShellUrl(url));
|
|
266
|
+
mainWin.show();
|
|
267
|
+
} else {
|
|
268
|
+
mainWin = createMainWindow(url);
|
|
269
|
+
}
|
|
270
|
+
notify('DeepSeek Harness', '本地引擎已就绪');
|
|
271
|
+
startEventsWatch();
|
|
272
|
+
if (pendingOpenSession) {
|
|
273
|
+
openSessionUrl(pendingOpenSession);
|
|
274
|
+
pendingOpenSession = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------- 业务事件通知(引擎 events.mux 下行流) ----------
|
|
279
|
+
// 审批请求 / 用户提问 / 任务轮次结束 → 原生通知。
|
|
280
|
+
// 仅当窗口不在前台(不可见或未聚焦)时通知,避免打扰正在使用的用户。
|
|
281
|
+
let eventsStop = null;
|
|
282
|
+
function startEventsWatch() {
|
|
283
|
+
if (eventsStop || !harnessUrl) return;
|
|
284
|
+
try {
|
|
285
|
+
const seenFrames = new Set(); // 每种帧类型记一次日志,避免刷屏
|
|
286
|
+
eventsStop = connectEvents(harnessUrl, {
|
|
287
|
+
onFrame: (frame) => {
|
|
288
|
+
const key = frame.event && frame.event.type ? `${frame.type}/${frame.event.type}` : frame.type;
|
|
289
|
+
if (!seenFrames.has(key)) {
|
|
290
|
+
seenFrames.add(key);
|
|
291
|
+
log('info', `events frame: ${key}`);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
onNotify: (info) => {
|
|
295
|
+
// 无条件记日志(前台/后台都能看到事件流),前台只抑制弹通知
|
|
296
|
+
log('info', `business event: ${info.kind} — ${info.title}: ${info.body}`);
|
|
297
|
+
const inFront = mainWin && !mainWin.isDestroyed() && mainWin.isVisible() && mainWin.isFocused();
|
|
298
|
+
if (inFront) return; // 用户正在用,不弹通知
|
|
299
|
+
notify(info.title, info.body);
|
|
300
|
+
},
|
|
301
|
+
onState: (s) => log('info', `events watcher: ${s}`),
|
|
302
|
+
});
|
|
303
|
+
log('info', `events watcher started (${harnessUrl}/api/events.mux)`);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
log('warn', `events watcher failed: ${e.message}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function stopEventsWatch() {
|
|
309
|
+
if (eventsStop) {
|
|
310
|
+
try { eventsStop.stop(); } catch { /* ignore */ }
|
|
311
|
+
eventsStop = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function onHarnessExit(code, sig) {
|
|
316
|
+
clearTimeout(startTimer); // 崩溃发生在启动超时前时,别让旧超时定时器误报
|
|
317
|
+
stopEventsWatch(); // 引擎已死,WS 必然断开;显式停掉避免对旧端口重连
|
|
318
|
+
const wasChild = child;
|
|
319
|
+
child = null;
|
|
320
|
+
stdoutBuf = '';
|
|
321
|
+
harnessUrl = null;
|
|
322
|
+
if (quitting || !wasChild) return;
|
|
323
|
+
log('warn', `harness exited (code=${code}, signal=${sig}) — restarting in 3s`);
|
|
324
|
+
notify('DeepSeek Harness', '本地引擎已停止,正在自动重启…');
|
|
325
|
+
showError('本地引擎已停止,3 秒后自动重启…');
|
|
326
|
+
clearTimeout(restartTimer);
|
|
327
|
+
restartTimer = setTimeout(() => {
|
|
328
|
+
// 合并兜底:退出可能因端口被占(现有引擎在 3080 而 probe 误判)。重启前先看 3080 是否活着,
|
|
329
|
+
// 活着 → 直接连接(不再 spawn,避免 EADDRINUSE 崩溃循环)
|
|
330
|
+
probePort(WEB_PORT, 1500, (alive) => {
|
|
331
|
+
if (alive) {
|
|
332
|
+
log('info', `engine exited but port ${WEB_PORT} alive — connecting to existing engine`);
|
|
333
|
+
onHarnessReady(`http://127.0.0.1:${WEB_PORT}`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
restartCount += 1;
|
|
337
|
+
log('info', `restart #${restartCount}`);
|
|
338
|
+
startHarness();
|
|
339
|
+
});
|
|
340
|
+
}, 3000);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------- 窗口 ----------
|
|
344
|
+
function createMainWindow(url) {
|
|
345
|
+
const state = loadJson(statePath, { width: 1280, height: 860 });
|
|
346
|
+
const opts = {
|
|
347
|
+
width: state.width || 1280,
|
|
348
|
+
height: state.height || 860,
|
|
349
|
+
...(Number.isInteger(state.x) && Number.isInteger(state.y) ? { x: state.x, y: state.y } : {}),
|
|
350
|
+
minWidth: 960,
|
|
351
|
+
minHeight: 600,
|
|
352
|
+
show: false,
|
|
353
|
+
title: 'DeepSeek Harness',
|
|
354
|
+
icon: nativeImage.createFromPath(path.join(ASSETS, 'icon-app.png')),
|
|
355
|
+
backgroundColor: '#14161a',
|
|
356
|
+
webPreferences: {
|
|
357
|
+
contextIsolation: true,
|
|
358
|
+
nodeIntegration: false,
|
|
359
|
+
sandbox: true,
|
|
360
|
+
spellcheck: false,
|
|
361
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
362
|
+
additionalArguments: [`--dsh-custom-titlebar=${settings.customTitleBar ? 1 : 0}`],
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
if (settings.customTitleBar) {
|
|
366
|
+
// 自绘标题栏:隐藏系统标题栏与原生窗口控件,UI(logo/应用名/窗口按钮)由 preload 注入,
|
|
367
|
+
// 风格对齐 Codex/VS Code;不用 titleBarOverlay —— 控件也自绘,随主题变色
|
|
368
|
+
opts.titleBarStyle = 'hidden';
|
|
369
|
+
}
|
|
370
|
+
const win = new BrowserWindow(opts);
|
|
371
|
+
win.loadURL(desktopShellUrl(url));
|
|
372
|
+
win.once('ready-to-show', () => {
|
|
373
|
+
if (state.maximized) win.maximize();
|
|
374
|
+
win.show();
|
|
375
|
+
});
|
|
376
|
+
win.on('close', () => { saveStateNow(); }); // 关闭=销毁窗口;应用驻留托盘,可从托盘重新打开
|
|
377
|
+
win.on('closed', () => { if (mainWin === win) mainWin = null; });
|
|
378
|
+
win.on('maximize', () => { saveStateDebounced(); win.webContents.send('win-maximized', true); });
|
|
379
|
+
win.on('unmaximize', () => { saveStateDebounced(); win.webContents.send('win-maximized', false); });
|
|
380
|
+
win.webContents.on('did-finish-load', () => {
|
|
381
|
+
// 页面(含错误页)加载完成时同步一次最大化状态,避免启动时错过事件
|
|
382
|
+
if (!win.isDestroyed()) win.webContents.send('win-maximized', win.isMaximized());
|
|
383
|
+
});
|
|
384
|
+
win.on('resize', saveStateDebounced);
|
|
385
|
+
win.on('move', saveStateDebounced);
|
|
386
|
+
attachShellGuards(win);
|
|
387
|
+
win.webContents.on('did-fail-load', (e, code, desc) => {
|
|
388
|
+
if (code !== -3) showError(`页面加载失败 (${code} ${desc})`);
|
|
389
|
+
});
|
|
390
|
+
return win;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function attachShellGuards(win) {
|
|
394
|
+
win.webContents.setWindowOpenHandler(({ url: u }) => {
|
|
395
|
+
if (/^https?:/i.test(u)) shell.openExternal(u);
|
|
396
|
+
return { action: 'deny' };
|
|
397
|
+
});
|
|
398
|
+
win.webContents.on('will-navigate', (e, u) => {
|
|
399
|
+
if (harnessUrl && !u.startsWith(harnessUrl)) e.preventDefault();
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 自绘标题栏避让声明:让 better-sidebar 等插件自动探测桌面壳,而不是靠手动 custom 配置。
|
|
404
|
+
function desktopShellUrl(url) {
|
|
405
|
+
try {
|
|
406
|
+
const u = new URL(url);
|
|
407
|
+
u.searchParams.set('dsh-desktop-mode', 'advanced');
|
|
408
|
+
u.searchParams.set('dsh-desktop-platform', process.platform);
|
|
409
|
+
u.searchParams.set('dsh-desktop-titlebar-inset', '32');
|
|
410
|
+
return u.toString();
|
|
411
|
+
} catch {
|
|
412
|
+
return url;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 会话窗口:与主窗口同壳(自绘标题栏/同 preload/同安全守卫),但独立窗口、独立会话。
|
|
417
|
+
function createSessionWindow(sessionId) {
|
|
418
|
+
const opts = {
|
|
419
|
+
width: 1100,
|
|
420
|
+
height: 760,
|
|
421
|
+
minWidth: 960,
|
|
422
|
+
minHeight: 600,
|
|
423
|
+
show: false,
|
|
424
|
+
title: 'DeepSeek Harness',
|
|
425
|
+
icon: nativeImage.createFromPath(path.join(ASSETS, 'icon-app.png')),
|
|
426
|
+
backgroundColor: '#14161a',
|
|
427
|
+
webPreferences: {
|
|
428
|
+
contextIsolation: true,
|
|
429
|
+
nodeIntegration: false,
|
|
430
|
+
sandbox: true,
|
|
431
|
+
spellcheck: false,
|
|
432
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
433
|
+
additionalArguments: [
|
|
434
|
+
`--dsh-custom-titlebar=${settings.customTitleBar ? 1 : 0}`,
|
|
435
|
+
`--dsh-open-session=${sessionId}`,
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
if (settings.customTitleBar) opts.titleBarStyle = 'hidden';
|
|
440
|
+
const win = new BrowserWindow(opts);
|
|
441
|
+
win.loadURL(desktopShellUrl(harnessUrl));
|
|
442
|
+
win.once('ready-to-show', () => win.show());
|
|
443
|
+
win.on('maximize', () => { if (!win.isDestroyed()) win.webContents.send('win-maximized', true); });
|
|
444
|
+
win.on('unmaximize', () => { if (!win.isDestroyed()) win.webContents.send('win-maximized', false); });
|
|
445
|
+
win.on('closed', () => sessionWindows.delete(win));
|
|
446
|
+
win.webContents.on('did-fail-load', (e, code, desc) => {
|
|
447
|
+
if (code !== -3) log('warn', `session window load failed (${code} ${desc})`);
|
|
448
|
+
});
|
|
449
|
+
win.webContents.on('did-finish-load', () => {
|
|
450
|
+
if (win.isDestroyed()) return;
|
|
451
|
+
win.webContents.send('win-maximized', win.isMaximized());
|
|
452
|
+
setTimeout(() => {
|
|
453
|
+
if (win.isDestroyed()) return;
|
|
454
|
+
win.webContents.executeJavaScript('localStorage.getItem("dsh.sessions.current")')
|
|
455
|
+
.then((raw) => {
|
|
456
|
+
const sid = parseCurrentSession(raw);
|
|
457
|
+
log('info', `session window loaded: ${sid || 'no current session'}`);
|
|
458
|
+
})
|
|
459
|
+
.catch((e) => log('warn', `session window state read failed: ${e.message}`));
|
|
460
|
+
}, 8000);
|
|
461
|
+
});
|
|
462
|
+
attachShellGuards(win);
|
|
463
|
+
return win;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function focusedShellWindow() {
|
|
467
|
+
const focused = BrowserWindow.getFocusedWindow();
|
|
468
|
+
if (focused && !focused.isDestroyed() && (focused === mainWin || sessionWindows.has(focused))) return focused;
|
|
469
|
+
return mainWin;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function openSessionUrl(sessionId) {
|
|
473
|
+
const win = createSessionWindow(sessionId);
|
|
474
|
+
sessionWindows.add(win);
|
|
475
|
+
log('info', `open session window: ${sessionId}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function openSessionWindowFrom(sourceWin) {
|
|
479
|
+
if (!harnessUrl) {
|
|
480
|
+
log('warn', 'open session window: engine not ready');
|
|
481
|
+
notify('无法打开新窗口', '本地引擎未就绪');
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const target = sourceWin || focusedShellWindow();
|
|
485
|
+
if (!target || target.isDestroyed()) {
|
|
486
|
+
log('warn', 'open session window: no source window');
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
let raw = null;
|
|
490
|
+
try {
|
|
491
|
+
raw = await target.webContents.executeJavaScript('localStorage.getItem("dsh.sessions.current")', true);
|
|
492
|
+
} catch (e) {
|
|
493
|
+
log('warn', `open session window: read current session failed: ${e.message}`);
|
|
494
|
+
}
|
|
495
|
+
const sessionId = parseCurrentSession(raw);
|
|
496
|
+
if (!sessionId) {
|
|
497
|
+
log('warn', 'open session window: current session id unavailable');
|
|
498
|
+
notify('无法打开新窗口', '未找到当前会话');
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
openSessionUrl(sessionId);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function saveStateDebounced() {
|
|
505
|
+
clearTimeout(stateTimer);
|
|
506
|
+
stateTimer = setTimeout(saveStateNow, 600);
|
|
507
|
+
}
|
|
508
|
+
function saveStateNow() {
|
|
509
|
+
if (!mainWin || mainWin.isDestroyed()) return;
|
|
510
|
+
const b = mainWin.getBounds();
|
|
511
|
+
saveJson(statePath, { x: b.x, y: b.y, width: b.width, height: b.height, maximized: mainWin.isMaximized() });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------- 启动画面 ----------
|
|
515
|
+
function createSplash() {
|
|
516
|
+
splash = new BrowserWindow({
|
|
517
|
+
width: 380, height: 230, frame: false, resizable: false,
|
|
518
|
+
alwaysOnTop: true, skipTaskbar: true, show: false,
|
|
519
|
+
backgroundColor: '#101216',
|
|
520
|
+
webPreferences: { sandbox: true },
|
|
521
|
+
});
|
|
522
|
+
splash.loadFile(path.join(__dirname, 'splash.html'));
|
|
523
|
+
splash.once('ready-to-show', () => splash.show());
|
|
524
|
+
splash.on('closed', () => { splash = null; });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ---------- 错误/重连页 ----------
|
|
528
|
+
function escapeHtml(s) {
|
|
529
|
+
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
530
|
+
}
|
|
531
|
+
function errorHtml(msg) {
|
|
532
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
533
|
+
body{margin:0;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;background:#101216;color:#e5e7eb;font-family:'Segoe UI',system-ui,sans-serif}
|
|
534
|
+
h1{font-size:18px;margin:0;color:#9aa4b2;font-weight:600}
|
|
535
|
+
p{font-size:13px;color:#6b7280;max-width:520px;text-align:center;line-height:1.6}
|
|
536
|
+
button{background:#4d6bfe;border:none;color:#fff;font-size:14px;padding:8px 24px;border-radius:8px;cursor:pointer}
|
|
537
|
+
button:hover{background:#3b5bfe}</style></head><body>
|
|
538
|
+
<h1>DeepSeek Harness</h1><p>${escapeHtml(msg)}</p>
|
|
539
|
+
<button onclick="window.dshNative && window.dshNative.retryHarness()">重试</button>
|
|
540
|
+
</body></html>`;
|
|
541
|
+
}
|
|
542
|
+
function showError(msg) {
|
|
543
|
+
log('error', msg);
|
|
544
|
+
if (!mainWin || mainWin.isDestroyed()) {
|
|
545
|
+
if (splash && !splash.isDestroyed()) splash.close();
|
|
546
|
+
mainWin = new BrowserWindow({
|
|
547
|
+
width: 720, height: 420, show: false, title: 'DeepSeek Harness',
|
|
548
|
+
icon: nativeImage.createFromPath(path.join(ASSETS, 'icon-app.png')), backgroundColor: '#101216',
|
|
549
|
+
// 固定对话框(设计系统「窗口框架」):禁止缩放/最大化,保留拖拽
|
|
550
|
+
resizable: false, maximizable: false, minimizable: false,
|
|
551
|
+
// 必须带 preload:错误页的「重试」按钮依赖 window.dshNative.retryHarness()
|
|
552
|
+
webPreferences: { sandbox: true, contextIsolation: true, preload: path.join(__dirname, 'preload.js') },
|
|
553
|
+
});
|
|
554
|
+
mainWin.once('ready-to-show', () => mainWin.show());
|
|
555
|
+
}
|
|
556
|
+
mainWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(errorHtml(msg)));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---------- 通知 / 托盘 / 快捷键 / 自启 ----------
|
|
560
|
+
function notify(title, body) {
|
|
561
|
+
if (!Notification.isSupported()) return;
|
|
562
|
+
try { new Notification({ title, body, icon: path.join(ASSETS, 'icon-app.png') }).show(); } catch { /* ignore */ }
|
|
563
|
+
}
|
|
564
|
+
function toggleWindow() {
|
|
565
|
+
if (!mainWin || mainWin.isDestroyed()) {
|
|
566
|
+
if (harnessUrl) mainWin = createMainWindow(harnessUrl);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (mainWin.isVisible() && mainWin.isFocused()) mainWin.hide();
|
|
570
|
+
else { mainWin.show(); mainWin.focus(); }
|
|
571
|
+
}
|
|
572
|
+
function createTray() {
|
|
573
|
+
// 托盘图标跟随系统主题(深色=白鲸,浅色=黑鲸),与窗口一致
|
|
574
|
+
try {
|
|
575
|
+
let icon = nativeImage.createFromPath(path.join(ASSETS, 'icon-app.png'));
|
|
576
|
+
if (icon.isEmpty()) icon = nativeImage.createFromPath(path.join(ASSETS, 'tray.png'));
|
|
577
|
+
if (icon.isEmpty()) icon = nativeImage.createEmpty();
|
|
578
|
+
tray = new Tray(icon);
|
|
579
|
+
tray.setToolTip('DeepSeek Harness');
|
|
580
|
+
refreshTrayMenu();
|
|
581
|
+
tray.on('click', toggleWindow);
|
|
582
|
+
log('info', 'tray created');
|
|
583
|
+
} catch (e) {
|
|
584
|
+
log('warn', `tray creation failed: ${e.message}`);
|
|
585
|
+
tray = null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function refreshTrayMenu() {
|
|
589
|
+
if (!tray) return;
|
|
590
|
+
tray.setContextMenu(Menu.buildFromTemplate([
|
|
591
|
+
{ label: '显示 / 隐藏主窗口', click: toggleWindow },
|
|
592
|
+
{ type: 'separator' },
|
|
593
|
+
{ label: '开机自启', type: 'checkbox', checked: !!settings.autostart, click: (item) => setAutostart(item.checked) },
|
|
594
|
+
{ label: `全局快捷键 ${settings.shortcut}`, enabled: false },
|
|
595
|
+
{ type: 'separator' },
|
|
596
|
+
{ label: '当前会话在新窗口打开', click: () => { openSessionWindowFrom(null); } },
|
|
597
|
+
{ label: '检查更新…', click: () => { runCheckUpdates(); } },
|
|
598
|
+
{ label: '开发者工具', click: () => { if (mainWin && !mainWin.isDestroyed()) mainWin.webContents.toggleDevTools(); } },
|
|
599
|
+
{ label: '退出', click: quitApp },
|
|
600
|
+
]));
|
|
601
|
+
}
|
|
602
|
+
function setAutostart(on) {
|
|
603
|
+
settings.autostart = on;
|
|
604
|
+
saveJson(settingsPath, settings);
|
|
605
|
+
try {
|
|
606
|
+
app.setLoginItemSettings({ openAtLogin: on, path: process.execPath, args: app.isPackaged ? [] : [app.getAppPath()] });
|
|
607
|
+
} catch (e) { log('warn', `setLoginItemSettings: ${e.message}`); }
|
|
608
|
+
refreshTrayMenu();
|
|
609
|
+
}
|
|
610
|
+
function killHarness() {
|
|
611
|
+
if (!child) return;
|
|
612
|
+
const pid = child.pid;
|
|
613
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
614
|
+
child = null; // 立即让出引用:手动重试/退出时不会触发自动重启分支
|
|
615
|
+
stdoutBuf = '';
|
|
616
|
+
harnessUrl = null;
|
|
617
|
+
if (process.platform === 'win32' && pid) {
|
|
618
|
+
// 确保进程树(含 npx/cmd 链)全部结束
|
|
619
|
+
execFile('taskkill', ['/pid', String(pid), '/t', '/f'], () => { /* ignore */ });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function quitApp() {
|
|
623
|
+
quitting = true;
|
|
624
|
+
clearTimeout(restartTimer); // 崩溃重启挂起时退出,避免定时器再 spawn 出孤儿引擎
|
|
625
|
+
clearTimeout(startTimer);
|
|
626
|
+
stopEventsWatch();
|
|
627
|
+
killHarness();
|
|
628
|
+
killWebEngine(); // 合并:托盘退出时顺带关闭共享的 web 端引擎(3080)
|
|
629
|
+
try { globalShortcut.unregisterAll(); } catch { /* ignore */ }
|
|
630
|
+
saveStateNow();
|
|
631
|
+
app.quit();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// 杀掉共享的 web 端引擎(启动参数含 "bin.js web ... --port N" 的 node 进程树)。
|
|
635
|
+
// 与 stop-web.js 同逻辑(打包后无法 require scripts,内联实现)。
|
|
636
|
+
function killWebEngine() {
|
|
637
|
+
try {
|
|
638
|
+
const r = require('node:child_process').execFileSync('powershell', [
|
|
639
|
+
'-NoProfile', '-NonInteractive', '-Command',
|
|
640
|
+
'Get-CimInstance Win32_Process -Filter "Name=\'node.exe\'" | Where-Object { $_.CommandLine -match \'bin\\.js.*web.*--port\' } | ForEach-Object { $_.ProcessId }',
|
|
641
|
+
], { encoding: 'utf8', timeout: 10000 });
|
|
642
|
+
for (const pid of r.trim().split(/\s+/).filter(Boolean)) {
|
|
643
|
+
log('info', `quit: killing web engine pid ${pid}`);
|
|
644
|
+
execFile('taskkill', ['/PID', pid, '/T', '/F'], () => { /* ignore */ });
|
|
645
|
+
}
|
|
646
|
+
} catch (e) {
|
|
647
|
+
log('warn', `killWebEngine failed: ${e.message}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ---------- IPC ----------
|
|
652
|
+
// ---------- IPC ----------
|
|
653
|
+
// 自绘标题栏:窗口控制(按钮点击由 preload 转发)
|
|
654
|
+
function ipcWindow(event) {
|
|
655
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
656
|
+
return win && !win.isDestroyed() ? win : null;
|
|
657
|
+
}
|
|
658
|
+
ipcMain.on('win-minimize', (event) => {
|
|
659
|
+
const win = ipcWindow(event);
|
|
660
|
+
if (win) win.minimize();
|
|
661
|
+
});
|
|
662
|
+
ipcMain.on('win-maximize-toggle', (event) => {
|
|
663
|
+
const win = ipcWindow(event);
|
|
664
|
+
if (!win) return;
|
|
665
|
+
if (win.isMaximized()) win.unmaximize(); else win.maximize();
|
|
666
|
+
});
|
|
667
|
+
ipcMain.on('win-close', (event) => {
|
|
668
|
+
const win = ipcWindow(event);
|
|
669
|
+
if (win) win.close();
|
|
670
|
+
});
|
|
671
|
+
// 标题栏鲸鱼 logo:主进程读官方 SVG(单一来源 assets/whale.svg)注入渲染页
|
|
672
|
+
ipcMain.handle('get-brand-svg', () => {
|
|
673
|
+
try { return fs.readFileSync(path.join(ASSETS, 'whale.svg'), 'utf8'); } catch { return null; }
|
|
674
|
+
});
|
|
675
|
+
ipcMain.on('notify', (_e, payload = {}) => {
|
|
676
|
+
if (payload.title) notify(payload.title, payload.body || '');
|
|
677
|
+
});
|
|
678
|
+
ipcMain.on('retry-harness', () => {
|
|
679
|
+
if (quitting) return;
|
|
680
|
+
if (child) killHarness(); // 启动超时/卡死时旧进程可能还活着,先清掉再重启
|
|
681
|
+
log('info', 'manual retry requested');
|
|
682
|
+
startHarness();
|
|
683
|
+
});
|
|
684
|
+
ipcMain.on('open-session-window', (event, payload) => {
|
|
685
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
686
|
+
const sid = payload && typeof payload.sessionId === 'string' && SESSION_ID_RE.test(payload.sessionId)
|
|
687
|
+
? payload.sessionId
|
|
688
|
+
: null;
|
|
689
|
+
if (sid) openSessionUrl(sid);
|
|
690
|
+
else if (win) openSessionWindowFrom(win);
|
|
691
|
+
else log('warn', 'open session window: unknown source window');
|
|
692
|
+
});
|
|
693
|
+
// 拖拽调整尺寸黑边修复:渲染进程把页面背景色同步为窗口底层色,
|
|
694
|
+
// resize 新增像素区域露出的底色与页面一致,不再闪黑边
|
|
695
|
+
ipcMain.on('set-window-bg', (_e, color) => {
|
|
696
|
+
const win = ipcWindow(_e);
|
|
697
|
+
if (typeof color !== 'string' || !win) return;
|
|
698
|
+
log('info', `set window bg -> ${color}`);
|
|
699
|
+
try { win.setBackgroundColor(color); } catch (e) { log('warn', `setBackgroundColor failed: ${e.message}`); }
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// ---------- 检查更新 / 自动更新(托盘入口) ----------
|
|
703
|
+
// 流程:对比版本 → 拉取 files.json → 本地差异 → 下载(逐文件 sha256 校验)
|
|
704
|
+
// → 重启自身进入 apply 模式 → .dsh-apply.cmd 收尾(exe 替换 + 清理 + 再启动)。
|
|
705
|
+
async function runCheckUpdates() {
|
|
706
|
+
const res = await checkForUpdates(settings.updateUrl, app.getVersion());
|
|
707
|
+
if (!res.ok) {
|
|
708
|
+
log('warn', `check update: ${res.error}`);
|
|
709
|
+
notify('检查更新', res.error);
|
|
710
|
+
return res;
|
|
711
|
+
}
|
|
712
|
+
log('info', `check update: current=${res.current} latest=${res.latest} hasUpdate=${res.hasUpdate}`);
|
|
713
|
+
if (!res.hasUpdate) {
|
|
714
|
+
notify('检查更新', `已是最新版本 v${res.current}`);
|
|
715
|
+
return res;
|
|
716
|
+
}
|
|
717
|
+
notify('检查更新', `发现新版本 v${res.latest}(当前 v${res.current}),开始下载…`);
|
|
718
|
+
const base = String(settings.updateUrl).replace(/\/+$/, '');
|
|
719
|
+
const appRoot = PACKAGE_ROOT;
|
|
720
|
+
const staging = path.join(appRoot, '.dsh-update-staging');
|
|
721
|
+
try {
|
|
722
|
+
const filesManifest = await fetchJson(base + '/files.json');
|
|
723
|
+
if (!Array.isArray(filesManifest.files)) throw new Error('files.json 缺少 files 数组');
|
|
724
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
725
|
+
const need = await diffLocal(appRoot, filesManifest.files);
|
|
726
|
+
if (need.length === 0) {
|
|
727
|
+
notify('检查更新', '文件已是最新,无需下载');
|
|
728
|
+
return res;
|
|
729
|
+
}
|
|
730
|
+
log('info', `update: ${need.length} files to download (${(need.reduce((s, f) => s + f.size, 0) / 1048576).toFixed(1)} MB)`);
|
|
731
|
+
await downloadUpdate(base, need, staging, (done, total) => {
|
|
732
|
+
if (done === total || done % 100 === 0) log('info', `update download: ${done}/${total}`);
|
|
733
|
+
});
|
|
734
|
+
notify('检查更新', `更新已就绪(v${res.latest}),正在重启应用…`);
|
|
735
|
+
// 重启自身进入 apply 模式;本进程随后退出,由新进程替换文件树
|
|
736
|
+
try {
|
|
737
|
+
spawn(process.execPath, ['--dsh-apply-update'], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
738
|
+
} catch (e) {
|
|
739
|
+
log('error', `spawn apply-update failed: ${e.message}`);
|
|
740
|
+
}
|
|
741
|
+
quitApp();
|
|
742
|
+
return { ...res, downloading: true };
|
|
743
|
+
} catch (e) {
|
|
744
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
745
|
+
const msg = e && e.message ? e.message : String(e);
|
|
746
|
+
log('error', `update failed: ${msg}`);
|
|
747
|
+
notify('检查更新', `更新失败: ${msg}`);
|
|
748
|
+
return { ok: false, error: msg };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ---------- 应用生命周期 ----------
|
|
753
|
+
process.on('uncaughtException', (e) => log('error', `uncaughtException: ${e && e.stack ? e.stack : e}`));
|
|
754
|
+
process.on('unhandledRejection', (r) => log('error', `unhandledRejection: ${r}`));
|
|
755
|
+
|
|
756
|
+
const gotLock = app.requestSingleInstanceLock();
|
|
757
|
+
if (!gotLock) {
|
|
758
|
+
app.quit();
|
|
759
|
+
} else {
|
|
760
|
+
app.on('second-instance', () => {
|
|
761
|
+
if (mainWin && !mainWin.isDestroyed()) {
|
|
762
|
+
if (mainWin.isMinimized()) mainWin.restore();
|
|
763
|
+
mainWin.show();
|
|
764
|
+
mainWin.focus();
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
app.whenReady().then(async () => {
|
|
769
|
+
// 只允许 loopback 页面请求权限(摄像头/通知等),其余一律拒绝
|
|
770
|
+
app.on('web-contents-created', (_e, wc) => {
|
|
771
|
+
wc.session.setPermissionRequestHandler((wc2, permission, cb) => {
|
|
772
|
+
cb(wc2.getURL().startsWith('http://127.0.0.1:'));
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
try { createSplash(); } catch (e) { log('warn', `splash failed: ${e.message}`); }
|
|
777
|
+
try { createTray(); } catch (e) { log('warn', `tray failed: ${e.message}`); }
|
|
778
|
+
|
|
779
|
+
if (settings.autostart) {
|
|
780
|
+
try {
|
|
781
|
+
if (!app.getLoginItemSettings().openAtLogin) {
|
|
782
|
+
app.setLoginItemSettings({ openAtLogin: true, path: process.execPath, args: app.isPackaged ? [] : [app.getAppPath()] });
|
|
783
|
+
}
|
|
784
|
+
} catch (e) { log('warn', `autostart check: ${e.message}`); }
|
|
785
|
+
}
|
|
786
|
+
try { globalShortcut.register(settings.shortcut, toggleWindow); }
|
|
787
|
+
catch (e) { log('warn', `global shortcut register failed: ${e.message}`); }
|
|
788
|
+
|
|
789
|
+
if (!(await ensureDataHome())) {
|
|
790
|
+
showError('独立数据根引导失败:源 profile 不存在且自动安装 dsh 未成功(需联网)。请检查网络后点击「重试」。');
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
startHarness();
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
app.on('window-all-closed', () => { /* 驻留托盘,不退出 */ });
|
|
798
|
+
app.on('before-quit', () => { quitting = true; clearTimeout(restartTimer); clearTimeout(startTimer); stopEventsWatch(); killHarness(); });
|
|
799
|
+
app.on('activate', () => { if (mainWin && !mainWin.isDestroyed()) mainWin.show(); });
|
|
800
|
+
|