dsh-auto-open-web 0.1.2

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/lib/index.js ADDED
@@ -0,0 +1,553 @@
1
+ /**
2
+ * @deepseek-ai/dsh-auto-open-web — dsh web profile 启动后自动打开独立应用窗口。
3
+ *
4
+ * 本文件为**平台无关共通核心**:配置模型、设置卡片 API、启动流程、生命周期、
5
+ * 图标栅格化。平台相关实现(浏览器候选/Job Object/进程清理/原生对话框/
6
+ * WebView2 宿主)全部位于平台适配器,经 ./platform.js 统一接口注入:
7
+ * - Windows: lib/win32.js(全量实现)
8
+ * - 其他平台: lib/posix.js(降级实现,记日志不打开)
9
+ *
10
+ * 行为:
11
+ * 1. 默认打开 **WebView2 宿主窗口**(随包分发的 DshAppWindow.exe,独立进程,
12
+ * 无标签栏/地址栏,直接加载 GUI 根地址):窗口/任务栏图标 = DSH(Form.Icon);
13
+ * 宿主监视父进程 PID,随 DSH 退出而关闭。
14
+ * 2. 宿主缺失/启动失败 → `--app` **专用浏览器实例**(--user-data-dir 隔离,
15
+ * 不与正常浏览器页面共用进程);再失败则不打开任何东西(仅记录日志)。
16
+ *
17
+ * 配置来源:行配置(cordis.patch.yml)作为启动种子;设置页卡片(客户端 bundle,
18
+ * settings.plugin.item 插槽)经 /auto-open-web/config API 读写,持久化到官方
19
+ * settings 命名空间 `auto-open-web`(首次保存后设置值优先于行配置)。
20
+ *
21
+ * 端口来自 webServer 服务的真实监听值(--port 自定义、--port 0 均正确)。
22
+ * 打开窗口的子进程 detached + unref,不阻塞 DSH、不随 DSH 退出被终止
23
+ * (WebView2 宿主例外:随 DSH 退出而关闭)。
24
+ */
25
+ import { spawn } from 'node:child_process';
26
+ import { rmSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import z from '@deepseek-ai/schemastery';
29
+ import { dshHome, appProfileDir, testProfileDir, instanceStateFile } from './paths.js';
30
+ import * as platform from './platform.js';
31
+
32
+ export const name = 'auto-open-web';
33
+
34
+ /**
35
+ * webServer 是硬依赖:Cordis 会等 webServer 插件 Service.init() 完成
36
+ * (HTTP socket 已绑定、port 已写入)后才激活本插件,因此 apply 时端口
37
+ * 直接可用,无需轮询等待。settings 是硬依赖(与 proxy-router 同款):
38
+ * 设置卡片的数据通道必须等到 settings 服务就绪后再注册命名空间,
39
+ * 否则 writable 为 false(只读)。
40
+ */
41
+ export const inject = ['webServer', 'settings'];
42
+
43
+ export const Config = z.object({
44
+ /** 启动时自动打开独立应用窗口;false 时不打开。 */
45
+ appWindow: z.boolean().default(true),
46
+ /** 窗口类型:webview2 = WebView2 宿主(独立进程,任务栏 DSH 图标,随 DSH 退出);
47
+ * browser = 浏览器 --app 专用实例(--user-data-dir 隔离)。所选类型不可用时
48
+ * 不打开任何东西(仅记录日志),不自动交叉兜底。 */
49
+ windowKind: z.union([z.const('webview2'), z.const('browser')]).default('webview2'),
50
+ /** 手动维护的浏览器可执行文件路径(单条;优先于内置候选 Edge → Chrome;仅 browser 模式使用)。 */
51
+ browserPath: z.string().default(''),
52
+ /** 关闭自动打开的窗口时随之退出 DSH(默认关闭;仅 appWindow 开启时生效)。 */
53
+ exitOnWindowClose: z.boolean().default(false),
54
+ });
55
+
56
+ const CONFIG_API_PATH = '/auto-open-web/config';
57
+ const PICK_API_PATH = '/auto-open-web/pick-browser';
58
+ const TEST_API_PATH = '/auto-open-web/test-browser';
59
+ const ICON_SIZES = [16, 32, 48, 64, 128, 256];
60
+ const SETTINGS_NS = 'auto-open-web';
61
+
62
+ // ── 启动流程工具(共通) ───────────────────────────────────────────────────
63
+
64
+ /**
65
+ * 测试浏览器能否被成功拉起(设置卡片「测试」按钮):
66
+ * 1. 以 --app 启动专用测试实例(独立 user-data-dir,不污染正式实例);
67
+ * 2. 等待 2.5 秒,确认浏览器主进程仍存活(启动器立即退出即视为失败);
68
+ * 3. 若平台 Job Object 可用则加入作业(DSH 退出时兜底清理);
69
+ * 4. 成功后在短暂展示(8 秒)内自动结束该测试进程树(平台进程树终止,
70
+ * 不动正式实例)。
71
+ * 返回 { ok:true, pid } | { ok:false, error }。
72
+ */
73
+ async function testBrowserLaunch(exe, browser, url) {
74
+ const args = [
75
+ '--app=' + url,
76
+ '--user-data-dir=' + testProfileDir(browser),
77
+ '--no-first-run',
78
+ '--no-default-browser-check',
79
+ ];
80
+ return new Promise((resolve) => {
81
+ let child;
82
+ try {
83
+ child = spawn(exe, args, { detached: true, stdio: 'ignore' });
84
+ } catch (error) {
85
+ resolve({ ok: false, error: `启动失败: ${error.message}` });
86
+ return;
87
+ }
88
+ let settled = false;
89
+ const fail = (error) => {
90
+ if (!settled) {
91
+ settled = true;
92
+ resolve({ ok: false, error });
93
+ }
94
+ };
95
+ child.on('error', (error) => fail(`浏览器进程启动失败: ${error.message}`));
96
+ setTimeout(async () => {
97
+ if (settled) return;
98
+ let alive = true;
99
+ try {
100
+ process.kill(child.pid, 0);
101
+ } catch {
102
+ alive = false;
103
+ }
104
+ if (!alive) {
105
+ settled = true;
106
+ resolve({ ok: false, error: '浏览器进程启动后立即退出' });
107
+ return;
108
+ }
109
+ // 加入平台 Job Object(可用时;非致命,定时清理仍在)
110
+ try {
111
+ const job = await platform.ensureBrowserJob();
112
+ platform.assignToBrowserJob(job, child.pid);
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ settled = true;
117
+ resolve({ ok: true, pid: child.pid });
118
+ // 短暂展示后自动清理测试实例(仅结束该测试进程树)
119
+ setTimeout(() => {
120
+ platform.killProcessTree(child.pid);
121
+ }, 8000);
122
+ }, 2500);
123
+ });
124
+ }
125
+
126
+ /**
127
+ * 用 --app 打开独立应用窗口(无标签栏/地址栏,外观同应用)。
128
+ * 使用专用 --user-data-dir:独立浏览器实例,不与正常浏览器页面共用进程/
129
+ * Cookie/缓存;--no-first-run 避免全新 profile 的首启欢迎页。
130
+ * 传入 job 时把子进程加入平台 Job Object(DSH 强退也随进程退出)。
131
+ * 始终注册 exit 监听:窗口进程正常退出(退出码 0,即用户关闭窗口)时,
132
+ * 是否随 DSH 退出由当前配置标志 exitWatcherEnabled 决定(设置卡片保存后
133
+ * 即时生效,当前会话无需重启;启动失败/崩溃/强杀的非 0 退出码不触发)。
134
+ */
135
+ function openAppWindow(exe, url, browser, job) {
136
+ const args = [
137
+ '--app=' + url,
138
+ '--user-data-dir=' + appProfileDir(browser ?? 'edge'),
139
+ '--no-first-run',
140
+ '--no-default-browser-check',
141
+ ];
142
+ try {
143
+ const child = spawn(exe, args, { detached: true, stdio: 'ignore' });
144
+ // 记录专用实例身份(pid/exe/写入时间),供平台清理校验(防 pid 复用误杀)
145
+ recordInstanceState(browser ?? 'edge', child.pid, exe);
146
+ if (job !== null && job.handle !== null) {
147
+ platform.assignToBrowserJob(job, child.pid); // 子进程及其后代自动继承作业成员资格
148
+ }
149
+ child.on('error', (error) => {
150
+ console.error(`[auto-open-web] app window spawn failed: ${error.message}`);
151
+ });
152
+ child.on('exit', (code) => {
153
+ if (code === 0 && exitWatcherEnabled) {
154
+ console.log('[auto-open-web] app window closed; exiting DSH (exitOnWindowClose)');
155
+ process.exit(0);
156
+ }
157
+ });
158
+ child.unref();
159
+ return true;
160
+ } catch (error) {
161
+ console.error(`[auto-open-web] app window spawn threw: ${error.message}`);
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 记录专用实例 pid 状态文件(平台清理时按文件校验进程身份)。
168
+ * 写入失败不致命:清理侧无记录时跳过(Job Object 仍是主要保障)。
169
+ */
170
+ function recordInstanceState(browser, pid, exe) {
171
+ try {
172
+ writeFileSync(instanceStateFile(browser), JSON.stringify({ pid, exe, writtenAt: Date.now() }), 'utf8');
173
+ } catch (error) {
174
+ console.error(`[auto-open-web] instance state write failed: ${error.message}`);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 启动 WebView2 宿主窗口(独立进程,detached 不共享控制台)。
180
+ * 传入 --parent-pid:宿主在后台线程监视 DSH 进程,DSH 退出(正常或强杀)
181
+ * 即关闭窗口 → 拉起的窗口随 DSH 一起退出,不留孤儿。
182
+ * 始终注册 exit 监听:宿主进程正常退出(退出码 0,即窗口被关闭)时,
183
+ * 是否随 DSH 退出由当前配置标志 exitWatcherEnabled 决定(保存后即时
184
+ * 生效;启动失败的非 0 退出码不触发)。
185
+ */
186
+ function spawnHostWindow(exe, url, iconPath) {
187
+ const args = ['--url', url, '--parent-pid', String(process.pid)];
188
+ if (iconPath !== null && iconPath !== '') args.push('--icon', iconPath);
189
+ try {
190
+ const child = spawn(exe, args, { detached: true, stdio: 'ignore' });
191
+ child.on('error', (error) => {
192
+ console.error(`[auto-open-web] host window spawn failed: ${error.message}`);
193
+ });
194
+ child.on('exit', (code) => {
195
+ if (code === 0 && exitWatcherEnabled) {
196
+ console.log('[auto-open-web] host window closed; exiting DSH (exitOnWindowClose)');
197
+ process.exit(0);
198
+ }
199
+ });
200
+ child.unref();
201
+ return true;
202
+ } catch (error) {
203
+ console.error(`[auto-open-web] host window spawn threw: ${error.message}`);
204
+ return false;
205
+ }
206
+ }
207
+
208
+ // ── 配置与历史迁移(共通) ─────────────────────────────────────────────────
209
+
210
+ /** 删除 v0.3 遗留的 state 文件(一次性清理,防孤儿数据)。 */
211
+ function removeLegacyState() {
212
+ try {
213
+ rmSync(join(dshHome(), 'auto-open-web.json'), { force: true });
214
+ } catch {
215
+ /* ignore */
216
+ }
217
+ }
218
+
219
+ /** 把任意输入规范化为完整配置(类型收紧 + 默认值;兼容旧 browsers 列表迁移)。 */
220
+ function normalizeState(next) {
221
+ const obj = next !== null && typeof next === 'object' ? next : {};
222
+ const legacyBrowser =
223
+ Array.isArray(obj.browsers) && obj.browsers.length > 0 && typeof obj.browsers[0] === 'string'
224
+ ? obj.browsers[0].trim()
225
+ : '';
226
+ return {
227
+ appWindow: obj.appWindow !== false,
228
+ windowKind: obj.windowKind === 'browser' ? 'browser' : 'webview2',
229
+ browserPath: typeof obj.browserPath === 'string' ? obj.browserPath.trim() : legacyBrowser,
230
+ exitOnWindowClose: obj.exitOnWindowClose === true,
231
+ };
232
+ }
233
+
234
+ // ── 图标栅格化(WebView2 宿主窗口图标需要 .ico;共通) ─────────────────────
235
+ //
236
+ // GUI 自带的 manifest 只有 SVG 图标(favicon.svg)。这里抓取本机 favicon.svg,
237
+ // 用 sharp(部署自带,运行时向上解析,未声明为依赖)栅格化为多尺寸 PNG 并缓存,
238
+ // 再组装成 .ico 供宿主窗口的 Form.Icon 使用;失败仅告警(宿主退回默认图标)。
239
+ let iconCache = null;
240
+ let iconFetching = null;
241
+
242
+ async function getIcons(server) {
243
+ if (iconCache !== null) return iconCache;
244
+ if (iconFetching !== null) return iconFetching;
245
+ iconFetching = (async () => {
246
+ try {
247
+ const port = server.port;
248
+ if (port === undefined) throw new Error('webServer port not settled');
249
+ const response = await fetch(`http://127.0.0.1:${String(port)}/favicon.svg`);
250
+ if (!response.ok) throw new Error(`favicon fetch failed: ${response.status}`);
251
+ const svg = Buffer.from(await response.arrayBuffer());
252
+ let sharp = null;
253
+ try {
254
+ sharp = (await import('sharp')).default;
255
+ } catch (error) {
256
+ throw new Error('sharp unavailable: ' + String(error && error.message !== undefined ? error.message : error));
257
+ }
258
+ const pngs = new Map();
259
+ for (const size of ICON_SIZES) {
260
+ pngs.set(size, await sharp(svg).resize(size, size).png().toBuffer());
261
+ }
262
+ iconCache = { pngs };
263
+ return iconCache;
264
+ } catch (error) {
265
+ console.error(`[auto-open-web] icon rasterization failed: ${String(error && error.message !== undefined ? error.message : error)}`);
266
+ return null;
267
+ } finally {
268
+ iconFetching = null;
269
+ }
270
+ })();
271
+ return iconFetching;
272
+ }
273
+
274
+ /** 把多尺寸 PNG 组装成 ICO 容器(Vista+ 支持 PNG 压缩条目)。 */
275
+ function buildIco(pngs) {
276
+ const sizes = [...pngs.keys()].sort((a, b) => a - b);
277
+ const header = Buffer.alloc(6);
278
+ header.writeUInt16LE(1, 2); // type: icon
279
+ header.writeUInt16LE(sizes.length, 4);
280
+ const entries = [];
281
+ let offset = 6 + 16 * sizes.length;
282
+ for (const size of sizes) {
283
+ const png = pngs.get(size);
284
+ const entry = Buffer.alloc(16);
285
+ entry.writeUInt8(size >= 256 ? 0 : size, 0); // width (0 = 256)
286
+ entry.writeUInt8(size >= 256 ? 0 : size, 1); // height
287
+ entry.writeUInt16LE(1, 4); // planes
288
+ entry.writeUInt16LE(32, 6); // bpp
289
+ entry.writeUInt32LE(png.length, 8);
290
+ entry.writeUInt32LE(offset, 12);
291
+ offset += png.length;
292
+ entries.push(entry);
293
+ }
294
+ return Buffer.concat([header, ...entries, ...sizes.map((s) => pngs.get(s))]);
295
+ }
296
+
297
+ /** 生成宿主窗口用的 DSH .ico(缓存于 ~/.dsh/auto-open-web-icon.ico),失败返回 null。 */
298
+ async function ensureIconFile(server) {
299
+ try {
300
+ const icons = await getIcons(server);
301
+ if (icons === null) return null;
302
+ const ico = buildIco(icons.pngs);
303
+ const out = join(dshHome(), 'auto-open-web-icon.ico');
304
+ writeFileSync(out, ico);
305
+ return out;
306
+ } catch (error) {
307
+ console.error(`[auto-open-web] icon file generation failed: ${error.message}`);
308
+ return null;
309
+ }
310
+ }
311
+
312
+ // ── 插件主体 ─────────────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * 窗口退出监听共享标志:"窗口关闭时退出 DSH"当前是否生效。
316
+ * 监听始终注册(见 openAppWindow/spawnHostWindow),行为由本标志实时决定:
317
+ * 设置卡片保存配置后立即同步,当前会话无需重启即生效。
318
+ */
319
+ let exitWatcherEnabled = false;
320
+
321
+ /** 测试钩子:纯函数导出,生产代码不依赖。 */
322
+ export const internals = {
323
+ resolveBrowserExe: platform.resolveBrowserExe,
324
+ openAppWindow,
325
+ testBrowserLaunch,
326
+ normalizeState,
327
+ getIcons,
328
+ buildIco,
329
+ resolveHostExe: platform.resolveHostExe,
330
+ spawnHostWindow,
331
+ /** 测试钩子:同步"窗口关闭时退出 DSH"生效标志(模拟设置保存)。 */
332
+ setExitWatcherEnabled(value) {
333
+ exitWatcherEnabled = value === true;
334
+ },
335
+ };
336
+
337
+ export function apply(ctx, config) {
338
+ removeLegacyState();
339
+
340
+ // browser 模式的专用实例随 DSH 退出:正常退出时结束其实例进程树
341
+ // (被强杀时由下次启动前的预清理兜底)。
342
+ process.on('exit', () => {
343
+ platform.killDedicatedBrowserInstances('edge');
344
+ platform.killDedicatedBrowserInstances('chrome');
345
+ });
346
+
347
+ const server = ctx.get('webServer');
348
+ if (server === undefined) {
349
+ console.error('[auto-open-web] webServer service unavailable; not opening anything');
350
+ return;
351
+ }
352
+
353
+ // ---- 配置状态:行配置为种子;settings 命名空间持久化(设置卡片写入) ----
354
+ const settingsSvc = ctx.get('settings');
355
+ let scope = null;
356
+ let writable = false;
357
+ let state = normalizeState(config);
358
+ /** 同步"窗口关闭时退出 DSH"生效标志:监听始终注册,保存配置后即时生效。 */
359
+ const syncExitWatcher = () => {
360
+ exitWatcherEnabled = state.exitOnWindowClose === true;
361
+ };
362
+ if (settingsSvc !== undefined && typeof settingsSvc.register === 'function') {
363
+ try {
364
+ scope = settingsSvc.register(SETTINGS_NS, Config);
365
+ writable = settingsSvc.writable !== false;
366
+ const rawDoc = settingsSvc.get(SETTINGS_NS);
367
+ if (rawDoc !== undefined && rawDoc !== null) {
368
+ state = normalizeState(scope.get());
369
+ }
370
+ scope.watch((next) => {
371
+ state = normalizeState(next);
372
+ syncExitWatcher();
373
+ });
374
+ } catch (error) {
375
+ console.error(`[auto-open-web] settings unavailable; using row config only: ${error.message}`);
376
+ scope = null;
377
+ writable = false;
378
+ }
379
+ }
380
+ syncExitWatcher(); // 启动时的最终生效值(settings 已合并或行配置)
381
+
382
+ // ---- 设置卡片 API(读写与测试,配置由宿主权威校验) ----
383
+ function sendJson(res, status, value) {
384
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
385
+ res.end(JSON.stringify(value));
386
+ }
387
+ async function readBody(req) {
388
+ let body = '';
389
+ for await (const chunk of req) body += chunk.toString('utf8');
390
+ return body;
391
+ }
392
+ if (typeof server.register === 'function') {
393
+ server.register({
394
+ kind: 'exact',
395
+ path: CONFIG_API_PATH,
396
+ handler: async (req, res) => {
397
+ if (req.method === 'GET') {
398
+ sendJson(res, 200, { ok: true, ...state, writable });
399
+ return;
400
+ }
401
+ if (req.method === 'POST') {
402
+ if (!writable) {
403
+ sendJson(res, 200, { ok: false, error: '当前配置只读(settings 不可写)' });
404
+ return;
405
+ }
406
+ let parsed;
407
+ try {
408
+ parsed = JSON.parse(await readBody(req));
409
+ } catch {
410
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
411
+ return;
412
+ }
413
+ try {
414
+ const next = normalizeState(parsed);
415
+ if (scope !== null) await scope.update(next);
416
+ state = next;
417
+ syncExitWatcher(); // 保存后即时生效,当前会话无需重启
418
+ sendJson(res, 200, { ok: true, ...state });
419
+ } catch (error) {
420
+ sendJson(res, 200, {
421
+ ok: false,
422
+ error: String(error && error.message !== undefined ? error.message : error),
423
+ });
424
+ }
425
+ return;
426
+ }
427
+ res.writeHead(405);
428
+ res.end();
429
+ },
430
+ });
431
+ // 原生"浏览"对话框:选择浏览器可执行文件(平台能力;非 Windows 提示手动输入)。
432
+ server.register({
433
+ kind: 'exact',
434
+ path: PICK_API_PATH,
435
+ handler: async (req, res) => {
436
+ if (req.method !== 'POST') {
437
+ res.writeHead(405);
438
+ res.end();
439
+ return;
440
+ }
441
+ if (!platform.isWindows) {
442
+ sendJson(res, 200, { ok: false, error: '原生浏览对话框仅支持 Windows;请手动输入路径' });
443
+ return;
444
+ }
445
+ const picked = await platform.pickBrowserExe();
446
+ if (picked !== null) {
447
+ sendJson(res, 200, { ok: true, path: picked });
448
+ } else {
449
+ sendJson(res, 200, { ok: false, cancelled: true, error: '未选择文件' });
450
+ }
451
+ },
452
+ });
453
+ // 测试按钮:真正拉起 --app 专用测试实例,确认浏览器可被成功启动。
454
+ server.register({
455
+ kind: 'exact',
456
+ path: TEST_API_PATH,
457
+ handler: async (req, res) => {
458
+ if (req.method !== 'POST') {
459
+ res.writeHead(405);
460
+ res.end();
461
+ return;
462
+ }
463
+ // 优先使用请求携带的草稿 browserPath(未保存也能测当前输入)
464
+ let draftPath = '';
465
+ try {
466
+ const parsed = JSON.parse(await readBody(req));
467
+ if (typeof parsed.browserPath === 'string') draftPath = parsed.browserPath.trim();
468
+ } catch {
469
+ /* 空 body 用已保存路径 */
470
+ }
471
+ const resolved = platform.resolveBrowserExe(draftPath !== '' ? draftPath : state.browserPath);
472
+ if (resolved === null) {
473
+ sendJson(res, 200, { ok: false, error: '未找到可用的浏览器可执行文件(内置候选 Edge/Chrome 均不可用);可先用「浏览」选择路径' });
474
+ return;
475
+ }
476
+ // inject 保证 apply 时端口已可用(inject 依赖 init 完成 = socket 已绑定)
477
+ if (server.port === undefined) {
478
+ sendJson(res, 200, { ok: false, error: 'webServer 端口不可用,请稍后重试' });
479
+ return;
480
+ }
481
+ const url = `http://127.0.0.1:${String(server.port)}`;
482
+ const result = await testBrowserLaunch(resolved.exe, resolved.browser, url);
483
+ if (result.ok !== true) {
484
+ sendJson(res, 200, { ok: false, error: result.error });
485
+ return;
486
+ }
487
+ const label = resolved.browser === 'edge' ? 'Edge' : resolved.browser === 'chrome' ? 'Chrome' : '浏览器';
488
+ sendJson(res, 200, {
489
+ ok: true,
490
+ browser: resolved.browser,
491
+ pid: result.pid,
492
+ message: `已成功拉起 ${label} 测试实例(pid ${result.pid}),窗口将在数秒后自动关闭`,
493
+ });
494
+ },
495
+ });
496
+ }
497
+
498
+ const attempt = async () => {
499
+ // inject 保证 apply 时 webServer 已初始化完成(HTTP socket 已绑定、
500
+ // port 已写入),直接取端口,无需等待。
501
+ if (server.port === undefined) {
502
+ console.error('[auto-open-web] webServer port unavailable; not opening anything');
503
+ return;
504
+ }
505
+ const url = `http://127.0.0.1:${String(server.port)}`;
506
+
507
+ // 独立应用窗口:按 windowKind 显式选择(webview2 | browser),直接加载 GUI 根地址。
508
+ // 所选类型不可用时仅记录日志,不打开任何东西,不自动交叉兜底。
509
+ if (state.appWindow) {
510
+ if (state.windowKind === 'browser') {
511
+ const resolved = platform.resolveBrowserExe(state.browserPath);
512
+ if (resolved !== null) {
513
+ // 预清理:上次 DSH 被强杀时残留的专用实例(避免旧窗口堆积)。
514
+ platform.killDedicatedBrowserInstances(resolved.browser);
515
+ // Job Object:DSH 无论正常退出还是被强杀,专用实例随进程一起结束。
516
+ const job = await platform.ensureBrowserJob();
517
+ const ok = openAppWindow(resolved.exe, url, resolved.browser, job);
518
+ if (ok) {
519
+ console.log(`[auto-open-web] opened browser app window (${resolved.browser} --app) at ${url}`);
520
+ return;
521
+ }
522
+ console.log('[auto-open-web] browser app window launch failed; not opening anything');
523
+ } else {
524
+ console.log('[auto-open-web] no Edge/Chrome executable found; not opening anything');
525
+ }
526
+ return;
527
+ }
528
+
529
+ // webview2 模式
530
+ if (!platform.isWindows) {
531
+ console.log('[auto-open-web] webview2 mode requires Windows; not opening anything');
532
+ return;
533
+ }
534
+ const hostExe = platform.resolveHostExe();
535
+ if (hostExe === null) {
536
+ console.log('[auto-open-web] host exe not found; not opening anything');
537
+ return;
538
+ }
539
+ const iconPath = await ensureIconFile(server);
540
+ const ok = spawnHostWindow(hostExe, url, iconPath);
541
+ if (ok) {
542
+ console.log(`[auto-open-web] opened host window (DshAppWindow) at ${url}`);
543
+ return;
544
+ }
545
+ console.log('[auto-open-web] host window launch failed; not opening anything');
546
+ return;
547
+ }
548
+
549
+ // appWindow 关闭:不自动打开任何窗口。
550
+ console.log('[auto-open-web] appWindow disabled; not opening anything');
551
+ };
552
+ void attempt();
553
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,27 @@
1
+ // dsh-auto-open-web — 数据目录与 profile 路径(跨平台共通,零平台依赖)。
2
+ import { join } from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ /** DSH 数据目录:DSH_HOME 优先,否则 ~/.dsh。 */
6
+ export function dshHome() {
7
+ return process.env.DSH_HOME && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : join(os.homedir(), '.dsh');
8
+ }
9
+
10
+ /** 专用浏览器实例的 user-data-dir(按浏览器区分,不与其他页面共用进程/存储)。 */
11
+ export function appProfileDir(browser) {
12
+ return join(dshHome(), browser === 'chrome' ? 'chrome-app-profile' : 'edge-app-profile');
13
+ }
14
+
15
+ /** 测试专用实例的 user-data-dir:独立于正式实例,测试不污染、正式预清理不误杀。 */
16
+ export function testProfileDir(browser) {
17
+ return join(dshHome(), (browser === 'chrome' ? 'chrome' : 'edge') + '-test-profile');
18
+ }
19
+
20
+ /**
21
+ * 专用实例 pid 状态文件:记录 {pid, exe, writtenAt},供平台清理时校验
22
+ * (进程身份 + 创建时间,防 pid 复用误杀)。custom 浏览器与 edge 共用
23
+ * edge-app-profile 目录,状态文件同样归入 edge。
24
+ */
25
+ export function instanceStateFile(browser) {
26
+ return join(dshHome(), (browser === 'chrome' ? 'chrome' : 'edge') + '-app-profile.json');
27
+ }
@@ -0,0 +1,27 @@
1
+ // dsh-auto-open-web — 平台适配器选择。
2
+ //
3
+ // 共通逻辑(index.js)只依赖本模块暴露的统一接口,不直接触碰平台差异:
4
+ // isWindows 平台标识
5
+ // resolveBrowserExe(path) → {browser, exe} | null
6
+ // ensureBrowserJob() → job | {handle:null}(降级标记)
7
+ // assignToBrowserJob(job, pid) → boolean
8
+ // killDedicatedBrowserInstances(browser)
9
+ // killProcessTree(pid)
10
+ // pickBrowserExe() → Promise<string | null>
11
+ // resolveHostExe() → string | null
12
+ //
13
+ // Windows 用 win32.js 全量实现;其余平台用 posix.js 降级实现。
14
+ // 静态 import 两个实现是安全的:两者顶层均无平台副作用。
15
+ import * as win32 from './win32.js';
16
+ import * as posix from './posix.js';
17
+
18
+ const impl = process.platform === 'win32' ? win32 : posix;
19
+
20
+ export const isWindows = impl.isWindows;
21
+ export const resolveBrowserExe = impl.resolveBrowserExe;
22
+ export const ensureBrowserJob = impl.ensureBrowserJob;
23
+ export const assignToBrowserJob = impl.assignToBrowserJob;
24
+ export const killDedicatedBrowserInstances = impl.killDedicatedBrowserInstances;
25
+ export const killProcessTree = impl.killProcessTree;
26
+ export const pickBrowserExe = impl.pickBrowserExe;
27
+ export const resolveHostExe = impl.resolveHostExe;
package/lib/posix.js ADDED
@@ -0,0 +1,42 @@
1
+ // dsh-auto-open-web — 非 Windows 平台适配器(降级/尽力而为实现)。
2
+ //
3
+ // 与 win32.js 保持同一接口(platform.js 按 process.platform 选择):
4
+ // - 浏览器候选:无(返回 null,调用方记日志不打开)
5
+ // - Job Object:不可用({handle:null} 降级,退出/预清理兜底)
6
+ // - 专用实例清理:无操作(Unix 下浏览器实例由 Job-less 方式管理,暂无实现)
7
+ // - 进程树终止:尽力而为(SIGKILL 单进程,后代进程不保证)
8
+ // - 原生文件对话框:不支持(返回 null,卡片提示手动输入)
9
+ // - WebView2 宿主:无(返回 null,webview2 模式记日志不打开)
10
+ export const isWindows = false;
11
+
12
+ export function resolveBrowserExe() {
13
+ return null;
14
+ }
15
+
16
+ export async function ensureBrowserJob() {
17
+ return { handle: null };
18
+ }
19
+
20
+ export function assignToBrowserJob() {
21
+ return false;
22
+ }
23
+
24
+ export function killDedicatedBrowserInstances() {
25
+ /* 无操作:Unix 暂无专用实例清理实现 */
26
+ }
27
+
28
+ export function killProcessTree(pid) {
29
+ try {
30
+ process.kill(pid, 'SIGKILL');
31
+ } catch {
32
+ /* ignore */
33
+ }
34
+ }
35
+
36
+ export function pickBrowserExe() {
37
+ return Promise.resolve(null);
38
+ }
39
+
40
+ export function resolveHostExe() {
41
+ return null;
42
+ }