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/LICENSE +18 -0
- package/README.en.md +181 -0
- package/README.md +167 -0
- package/cordis.patch.yml +5 -0
- package/host-publish/DshAppWindow.deps.json +94 -0
- package/host-publish/DshAppWindow.dll +0 -0
- package/host-publish/DshAppWindow.exe +0 -0
- package/host-publish/DshAppWindow.pdb +0 -0
- package/host-publish/DshAppWindow.runtimeconfig.json +20 -0
- package/host-publish/Microsoft.Web.WebView2.Core.dll +0 -0
- package/host-publish/Microsoft.Web.WebView2.Core.xml +6662 -0
- package/host-publish/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/host-publish/Microsoft.Web.WebView2.WinForms.xml +504 -0
- package/host-publish/Microsoft.Web.WebView2.Wpf.dll +0 -0
- package/host-publish/Microsoft.Web.WebView2.Wpf.xml +1902 -0
- package/host-publish/runtimes/win-arm64/native/WebView2Loader.dll +0 -0
- package/host-publish/runtimes/win-x64/native/WebView2Loader.dll +0 -0
- package/host-publish/runtimes/win-x86/native/WebView2Loader.dll +0 -0
- package/lib/client.js +398 -0
- package/lib/index.js +553 -0
- package/lib/paths.js +27 -0
- package/lib/platform.js +27 -0
- package/lib/posix.js +42 -0
- package/lib/win32.js +347 -0
- package/lib/worker.cjs +174 -0
- package/package.json +47 -0
package/lib/win32.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// dsh-auto-open-web — Windows 平台适配器。
|
|
2
|
+
//
|
|
3
|
+
// 本文件集中全部 win32 专属实现(共通逻辑见 index.js):
|
|
4
|
+
// - 浏览器可执行文件候选(ProgramFiles 布局)
|
|
5
|
+
// - Job Object(koffi 驱动,DSH 强退也随进程退出)
|
|
6
|
+
// - 专用实例进程树清理(pid 状态文件 + 进程身份校验 + taskkill,
|
|
7
|
+
// 不使用 PowerShell)
|
|
8
|
+
// - 进程树终止(taskkill,供测试实例自动清理)
|
|
9
|
+
// - 原生文件对话框(worker.cjs 子进程 + koffi IFileOpenDialog)
|
|
10
|
+
// - WebView2 宿主可执行文件解析(host-publish/DshAppWindow.exe)
|
|
11
|
+
//
|
|
12
|
+
// 平台选择由 platform.js 完成;本文件顶层无平台副作用,非 Windows 上
|
|
13
|
+
// 静态 import 也安全,各函数在 process.platform !== 'win32' 时自行降级。
|
|
14
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
16
|
+
import { join, basename } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { instanceStateFile } from './paths.js';
|
|
19
|
+
|
|
20
|
+
export const isWindows = process.platform === 'win32';
|
|
21
|
+
|
|
22
|
+
// koffi 在模块求值时预载(ESM 顶层 await):后续所有函数——包括
|
|
23
|
+
// process 'exit' 处理器——都能同步使用,无需异步 import。
|
|
24
|
+
// 加载失败不致命:进程身份校验降级为保守跳过(见 verifyInstancePid)。
|
|
25
|
+
let koffiLib = null;
|
|
26
|
+
let procApi = null;
|
|
27
|
+
try {
|
|
28
|
+
koffiLib = (await import('koffi')).default;
|
|
29
|
+
const kernel32 = koffiLib.load('kernel32.dll');
|
|
30
|
+
const psapi = koffiLib.load('psapi.dll');
|
|
31
|
+
procApi = {
|
|
32
|
+
openProcess: kernel32.func('__stdcall', 'OpenProcess', 'void *', ['uint32', 'int', 'uint32']),
|
|
33
|
+
getImageName: psapi.func('__stdcall', 'GetProcessImageFileNameW', 'uint32', ['void *', 'void *', 'uint32']),
|
|
34
|
+
getProcessTimes: kernel32.func('__stdcall', 'GetProcessTimes', 'int', ['void *', 'void *', 'void *', 'void *', 'void *']),
|
|
35
|
+
closeHandle: kernel32.func('__stdcall', 'CloseHandle', 'int', ['void *']),
|
|
36
|
+
fileTime: koffiLib.struct('DshFileTime', { dwLowDateTime: 'uint32', dwHighDateTime: 'uint32' }),
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
procApi = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
|
43
|
+
const FILE_TIME_MS = 10000; // 100ns 单位 → 毫秒
|
|
44
|
+
const FILETIME_UNIX_EPOCH_MS = 11644473600000; // 1601-01-01 → 1970-01-01 的毫秒差
|
|
45
|
+
|
|
46
|
+
// ── 浏览器可执行文件候选(仅 Windows 的安装布局) ──────────────────────────
|
|
47
|
+
|
|
48
|
+
const BROWSER_EXE_CANDIDATES = [
|
|
49
|
+
{
|
|
50
|
+
browser: 'edge',
|
|
51
|
+
candidates: [
|
|
52
|
+
join(process.env['ProgramFiles(x86)'] ?? '', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
53
|
+
join(process.env.ProgramFiles ?? '', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
browser: 'chrome',
|
|
58
|
+
candidates: [
|
|
59
|
+
join(process.env.ProgramFiles ?? '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
60
|
+
join(process.env['ProgramFiles(x86)'] ?? '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 解析浏览器可执行文件(仅 Windows):手动路径(browserPath)优先,
|
|
67
|
+
* 其次内置候选(Edge → Chrome)。手动路径不存在时记录并跳过。
|
|
68
|
+
*/
|
|
69
|
+
export function resolveBrowserExe(browserPath) {
|
|
70
|
+
if (process.platform !== 'win32') return null;
|
|
71
|
+
const p = typeof browserPath === 'string' ? browserPath.trim() : '';
|
|
72
|
+
if (p !== '') {
|
|
73
|
+
if (existsSync(p)) return { browser: 'custom', exe: p };
|
|
74
|
+
console.warn(`[auto-open-web] configured browser path not found, skipping: ${p}`);
|
|
75
|
+
}
|
|
76
|
+
for (const entry of BROWSER_EXE_CANDIDATES) {
|
|
77
|
+
const exe = entry.candidates.find((p) => p !== '' && existsSync(p));
|
|
78
|
+
if (exe !== undefined) return { browser: entry.browser, exe };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── browser 专用实例的 Job Object(DSH 强退也随进程退出) ──────────────────
|
|
84
|
+
//
|
|
85
|
+
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE:当作业的最后一个句柄关闭(即持有它的
|
|
86
|
+
// DSH 进程退出——无论正常退出还是被强杀)时,Windows 内核自动结束作业内
|
|
87
|
+
// 所有进程。子进程自动继承作业成员资格,因此只需把启动的 msedge 进程加入
|
|
88
|
+
// 作业,其整个实例进程树就随 DSH 一起消亡,不依赖 exit 事件。
|
|
89
|
+
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
|
90
|
+
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9;
|
|
91
|
+
const PROCESS_SET_QUOTA = 0x0100;
|
|
92
|
+
const PROCESS_TERMINATE = 0x0001;
|
|
93
|
+
|
|
94
|
+
/** { handle, openProc, closeHandle, assign } | { handle: null }(创建失败时的降级标记)。 */
|
|
95
|
+
let browserJob = null;
|
|
96
|
+
|
|
97
|
+
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION(x64) 的 koffi 结构定义。koffi 的 struct
|
|
98
|
+
// 名是全局注册的,重复定义同名 struct 会报错,因此只建一次。
|
|
99
|
+
// 布局(实测 sizeof = 144 字节):LimitFlags 位于偏移 16(BasicLimitInformation
|
|
100
|
+
// 内),即本结构第 3 个字段。flat 字段顺序与 Windows 定义一致时偏移自然对齐。
|
|
101
|
+
let jobExtType = null;
|
|
102
|
+
|
|
103
|
+
export async function ensureBrowserJob() {
|
|
104
|
+
if (browserJob !== null) return browserJob;
|
|
105
|
+
try {
|
|
106
|
+
const koffi = (await import('koffi')).default;
|
|
107
|
+
const kernel32 = koffi.load('kernel32.dll');
|
|
108
|
+
const createJob = kernel32.func('__stdcall', 'CreateJobObjectW', 'void *', ['void *', 'void *']);
|
|
109
|
+
const setInfo = kernel32.func('__stdcall', 'SetInformationJobObject', 'int', ['void *', 'uint32', 'void *', 'uint32']);
|
|
110
|
+
const assign = kernel32.func('__stdcall', 'AssignProcessToJobObject', 'int', ['void *', 'void *']);
|
|
111
|
+
const openProc = kernel32.func('__stdcall', 'OpenProcess', 'void *', ['uint32', 'int', 'uint32']);
|
|
112
|
+
const closeHandle = kernel32.func('__stdcall', 'CloseHandle', 'int', ['void *']);
|
|
113
|
+
const job = createJob(null, null);
|
|
114
|
+
if (job === null || job === 0) throw new Error('CreateJobObjectW failed');
|
|
115
|
+
if (jobExtType === null) {
|
|
116
|
+
jobExtType = koffi.struct('JobObjectExtendedLimitInformation', {
|
|
117
|
+
PerProcessUserTimeLimit: 'int64',
|
|
118
|
+
PerJobUserTimeLimit: 'int64',
|
|
119
|
+
LimitFlags: 'uint32',
|
|
120
|
+
MinimumWorkingSetSize: 'int64',
|
|
121
|
+
MaximumWorkingSetSize: 'int64',
|
|
122
|
+
ActiveProcessLimit: 'uint32',
|
|
123
|
+
Affinity: 'int64',
|
|
124
|
+
PriorityClass: 'uint32',
|
|
125
|
+
SchedulingClass: 'uint32',
|
|
126
|
+
IoReadOperationCount: 'int64',
|
|
127
|
+
IoWriteOperationCount: 'int64',
|
|
128
|
+
IoOtherOperationCount: 'int64',
|
|
129
|
+
IoReadTransferCount: 'int64',
|
|
130
|
+
IoWriteTransferCount: 'int64',
|
|
131
|
+
IoOtherTransferCount: 'int64',
|
|
132
|
+
ProcessMemoryLimit: 'int64',
|
|
133
|
+
JobMemoryLimit: 'int64',
|
|
134
|
+
PeakProcessMemoryUsed: 'int64',
|
|
135
|
+
PeakJobMemoryUsed: 'int64',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// koffi.decode 对结构体是值拷贝:必须用 koffi.encode 写入内存。
|
|
139
|
+
// 只设置 LimitFlags 一个字段,其余保持零值(不施加其他作业限制)。
|
|
140
|
+
const mem = koffi.alloc(jobExtType, koffi.sizeof(jobExtType));
|
|
141
|
+
koffi.encode(mem, jobExtType, { LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE });
|
|
142
|
+
if (setInfo(job, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, mem, koffi.sizeof(jobExtType)) === 0) {
|
|
143
|
+
closeHandle(job);
|
|
144
|
+
throw new Error('SetInformationJobObject failed');
|
|
145
|
+
}
|
|
146
|
+
browserJob = { handle: job, openProc, closeHandle, assign };
|
|
147
|
+
console.log('[auto-open-web] browser job object ready (KILL_ON_JOB_CLOSE)');
|
|
148
|
+
return browserJob;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error(`[auto-open-web] job object unavailable: ${error.message}; falling back to exit/pre-launch cleanup`);
|
|
151
|
+
browserJob = { handle: null };
|
|
152
|
+
return browserJob;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 把 pid 加入浏览器作业;失败返回 false(不致命,exit/预清理仍兜底)。 */
|
|
157
|
+
export function assignToBrowserJob(job, pid) {
|
|
158
|
+
if (job === null || job.handle === null) return false;
|
|
159
|
+
try {
|
|
160
|
+
const h = job.openProc(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid);
|
|
161
|
+
if (h === null || h === 0) return false;
|
|
162
|
+
try {
|
|
163
|
+
return job.assign(job.handle, h) !== 0;
|
|
164
|
+
} finally {
|
|
165
|
+
job.closeHandle(h);
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── 专用实例清理与进程树终止 ─────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 校验 pid 状态文件指向的进程是否仍是我们的专用实例(防 pid 复用误杀):
|
|
176
|
+
* - 进程必须存在(不存在 → 'gone');
|
|
177
|
+
* - 镜像文件名必须与记录一致(被复用为其他进程 → 'mismatch');
|
|
178
|
+
* - 进程创建时间必须早于状态文件写入时间(被复用的新进程必然更晚
|
|
179
|
+
* → 'mismatch')。
|
|
180
|
+
* 返回 'match' | 'gone' | 'mismatch'。koffi 不可用时保守返回 'mismatch'
|
|
181
|
+
* (不杀,安全优先)。
|
|
182
|
+
*/
|
|
183
|
+
function verifyInstancePid(pid, state) {
|
|
184
|
+
if (procApi === null) {
|
|
185
|
+
console.warn('[auto-open-web] process verification unavailable; skipping instance cleanup');
|
|
186
|
+
return 'mismatch';
|
|
187
|
+
}
|
|
188
|
+
const h = procApi.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
|
189
|
+
if (h === null || h === 0) return 'gone';
|
|
190
|
+
try {
|
|
191
|
+
const buf = koffiLib.alloc('uint16', 1024);
|
|
192
|
+
const len = procApi.getImageName(h, buf, 1024);
|
|
193
|
+
if (len === 0) return 'gone';
|
|
194
|
+
const image = koffiLib.decode(buf, 'char16', len);
|
|
195
|
+
const expected = typeof state.exe === 'string' && state.exe !== '' ? basename(state.exe).toLowerCase() : '';
|
|
196
|
+
if (expected !== '' && basename(image).toLowerCase() !== expected) return 'mismatch';
|
|
197
|
+
// 创建时间(100ns FILETIME)→ 毫秒;须早于状态文件写入时间(容差 5 秒)。
|
|
198
|
+
// 注意:koffi 3.x 无 koffi.offset,用 4 个独立分配的结构指针(alloc 的
|
|
199
|
+
// count 是元素数,1 = 单个 FILETIME)。
|
|
200
|
+
const creationFt = koffiLib.alloc(procApi.fileTime, 1);
|
|
201
|
+
const exitFt = koffiLib.alloc(procApi.fileTime, 1);
|
|
202
|
+
const kernelFt = koffiLib.alloc(procApi.fileTime, 1);
|
|
203
|
+
const userFt = koffiLib.alloc(procApi.fileTime, 1);
|
|
204
|
+
if (procApi.getProcessTimes(h, creationFt, exitFt, kernelFt, userFt) === 0) return 'mismatch';
|
|
205
|
+
const creation = koffiLib.decode(creationFt, procApi.fileTime);
|
|
206
|
+
const createdMs =
|
|
207
|
+
(Number(creation.dwHighDateTime) * 4294967296 + Number(creation.dwLowDateTime)) / FILE_TIME_MS -
|
|
208
|
+
FILETIME_UNIX_EPOCH_MS;
|
|
209
|
+
if (typeof state.writtenAt === 'number' && createdMs > state.writtenAt + 5000) return 'mismatch';
|
|
210
|
+
return 'match';
|
|
211
|
+
} catch {
|
|
212
|
+
return 'mismatch';
|
|
213
|
+
} finally {
|
|
214
|
+
procApi.closeHandle(h);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 结束指定浏览器类型的专用实例进程树(仅我们自己的专用实例,不影响正常
|
|
220
|
+
* 浏览器页面)。基于 pid 状态文件 + 进程身份校验(见 verifyInstancePid),
|
|
221
|
+
* 不使用 PowerShell。同步执行,可在 process 'exit' 处理器中使用。
|
|
222
|
+
* 进程已不存在时顺带删除状态文件(下次不再处理)。
|
|
223
|
+
*/
|
|
224
|
+
export function killDedicatedBrowserInstances(browser) {
|
|
225
|
+
if (process.platform !== 'win32') return;
|
|
226
|
+
const statePath = instanceStateFile(browser);
|
|
227
|
+
let state = null;
|
|
228
|
+
try {
|
|
229
|
+
state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
230
|
+
} catch {
|
|
231
|
+
return; // 无状态文件/损坏 → 无记录可清理
|
|
232
|
+
}
|
|
233
|
+
const pid = state !== null && typeof state === 'object' ? state.pid : 0;
|
|
234
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
235
|
+
const verdict = verifyInstancePid(pid, state);
|
|
236
|
+
if (verdict === 'gone') {
|
|
237
|
+
try {
|
|
238
|
+
rmSync(statePath, { force: true });
|
|
239
|
+
} catch {
|
|
240
|
+
/* ignore */
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (verdict !== 'match') return; // 校验不过:pid 已被复用或无法确认,不误杀
|
|
245
|
+
try {
|
|
246
|
+
spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
|
|
247
|
+
stdio: 'ignore',
|
|
248
|
+
windowsHide: true,
|
|
249
|
+
timeout: 15000,
|
|
250
|
+
});
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.error(`[auto-open-web] dedicated instance cleanup failed: ${error.message}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 结束 pid 的整个进程树(同步,可在定时清理中使用)。 */
|
|
257
|
+
export function killProcessTree(pid) {
|
|
258
|
+
if (process.platform !== 'win32') {
|
|
259
|
+
try {
|
|
260
|
+
process.kill(pid, 'SIGKILL');
|
|
261
|
+
} catch {
|
|
262
|
+
/* ignore */
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
|
|
268
|
+
stdio: 'ignore',
|
|
269
|
+
windowsHide: true,
|
|
270
|
+
timeout: 15000,
|
|
271
|
+
});
|
|
272
|
+
} catch {
|
|
273
|
+
/* ignore */
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── 原生文件对话框(仅 Windows) ───────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 弹出原生"打开文件"对话框选择浏览器可执行文件(仅 Windows)。
|
|
281
|
+
* 实现参考官方工作区目录选择器(@deepseek-ai/dsh-host-directory-picker-native):
|
|
282
|
+
* 生成子进程(worker.cjs)用 koffi 驱动 IFileOpenDialog(文件模式),
|
|
283
|
+
* 对话框是子进程的第一个窗口,Windows 自动激活 → 总是出现在浏览器上方。
|
|
284
|
+
* IPC 协议:{showing} → {done,path|null} | {error,message}。
|
|
285
|
+
* 返回选中路径,取消/失败返回 null;10 分钟无结果则终止子进程。
|
|
286
|
+
*/
|
|
287
|
+
export function pickBrowserExe() {
|
|
288
|
+
if (process.platform !== 'win32') return Promise.resolve(null);
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
const workerPath = fileURLToPath(new URL('./worker.cjs', import.meta.url));
|
|
291
|
+
let child;
|
|
292
|
+
try {
|
|
293
|
+
child = spawn(process.execPath, [workerPath], {
|
|
294
|
+
env: { ...process.env, DSH_DIALOG_TITLE: '选择浏览器可执行文件' },
|
|
295
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
296
|
+
windowsHide: true,
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
console.error(`[auto-open-web] native picker spawn failed: ${error.message}`);
|
|
300
|
+
resolve(null);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
let settled = false;
|
|
304
|
+
const finish = (value) => {
|
|
305
|
+
if (settled) return;
|
|
306
|
+
settled = true;
|
|
307
|
+
resolve(value);
|
|
308
|
+
};
|
|
309
|
+
const timer = setTimeout(() => {
|
|
310
|
+
try {
|
|
311
|
+
child.kill();
|
|
312
|
+
} catch {
|
|
313
|
+
/* ignore */
|
|
314
|
+
}
|
|
315
|
+
}, 10 * 60 * 1000);
|
|
316
|
+
child.on('message', (message) => {
|
|
317
|
+
if (message === null || typeof message !== 'object') return;
|
|
318
|
+
if (message.kind === 'done') {
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
finish(typeof message.path === 'string' && message.path !== '' ? message.path : null);
|
|
321
|
+
} else if (message.kind === 'error') {
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
console.error('[auto-open-web] native picker failed: ' + String(message.message ?? 'unknown error'));
|
|
324
|
+
finish(null);
|
|
325
|
+
}
|
|
326
|
+
/* 'showing' 仅用于中止通道,本实现超时即杀子进程,无需跟踪 */
|
|
327
|
+
});
|
|
328
|
+
child.on('error', (error) => {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
console.error(`[auto-open-web] native picker process error: ${error.message}`);
|
|
331
|
+
finish(null);
|
|
332
|
+
});
|
|
333
|
+
child.on('exit', () => {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
finish(null);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── WebView2 宿主可执行文件(仅 Windows) ──────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/** 解析随包分发的宿主可执行文件(仅 Windows)。 */
|
|
343
|
+
export function resolveHostExe() {
|
|
344
|
+
if (process.platform !== 'win32') return null;
|
|
345
|
+
const exe = fileURLToPath(new URL('../host-publish/DshAppWindow.exe', import.meta.url));
|
|
346
|
+
return existsSync(exe) ? exe : null;
|
|
347
|
+
}
|
package/lib/worker.cjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// dsh-auto-open-web — Win32 文件选择对话框子进程。
|
|
2
|
+
// 实现参考官方 @deepseek-ai/dsh-host-directory-picker-native 的 worker.cjs
|
|
3
|
+
// (koffi 驱动 IFileOpenDialog 的 COM 对话),仅两处差异:
|
|
4
|
+
// 1. 选项位:去掉 FOS_PICKFOLDERS,改为文件模式
|
|
5
|
+
// FOS_FORCEFILESYSTEM(0x40) | FOS_PATHMUSTEXIST(0x800) | FOS_FILEMUSTEXIST(0x1000)
|
|
6
|
+
// = 0x1840 = 6208;
|
|
7
|
+
// 2. 标题:由环境变量 DSH_DIALOG_TITLE 传入(与官方一致)。
|
|
8
|
+
// 子进程的第一个窗口就是对话框,Windows 会自动激活它(无需前台调用),
|
|
9
|
+
// 因此对话框总是出现在浏览器上方。
|
|
10
|
+
//
|
|
11
|
+
// IPC 协议(与官方一致):{kind:'showing',threadId} → {kind:'done',path|null}
|
|
12
|
+
// 或 {kind:'error',message}。被杀死/退出未报告 → 视为取消。
|
|
13
|
+
//
|
|
14
|
+
// COM vtable 槽位(IUnknown 0-2,IModalWindow 3,IFileDialog 4+)为 Windows Vista
|
|
15
|
+
// 以来冻结的 ABI,常量与官方实现逐字一致。
|
|
16
|
+
|
|
17
|
+
const SIGDN_FILESYSPATH = -2147123200;
|
|
18
|
+
const COINIT_APARTMENTTHREADED = 2;
|
|
19
|
+
const CLSCTX_INPROC_SERVER = 1;
|
|
20
|
+
const FILE_DIALOG_OPTIONS = 0x1840; // FORCEFILESYSTEM | PATHMUSTEXIST | FILEMUSTEXIST
|
|
21
|
+
const SLOT_RELEASE = 2;
|
|
22
|
+
const SLOT_SHOW = 3;
|
|
23
|
+
const SLOT_SET_OPTIONS = 9;
|
|
24
|
+
const SLOT_SET_TITLE = 17;
|
|
25
|
+
const SLOT_GET_RESULT = 20;
|
|
26
|
+
const SLOT_GET_DISPLAY_NAME = 5;
|
|
27
|
+
const DPI_AWARENESS_CONTEXTS = [-4, -3, -2];
|
|
28
|
+
|
|
29
|
+
function readUtf16(koffi, address) {
|
|
30
|
+
const bytes = Buffer.from(koffi.view(address, 32768));
|
|
31
|
+
let end = 0;
|
|
32
|
+
while (end + 1 < bytes.length && bytes[end] !== 0) end += 2;
|
|
33
|
+
return bytes.toString('utf16le', 0, end);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function guidBytes(text) {
|
|
37
|
+
const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text);
|
|
38
|
+
const bytes = Buffer.alloc(16);
|
|
39
|
+
bytes.writeUInt32LE(parseInt(match[1], 16), 0);
|
|
40
|
+
bytes.writeUInt16LE(parseInt(match[2], 16), 4);
|
|
41
|
+
bytes.writeUInt16LE(parseInt(match[3], 16), 6);
|
|
42
|
+
Buffer.from(match[4] + match[5], 'hex').copy(bytes, 8);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CLSID_FILE_OPEN_DIALOG = guidBytes('dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7');
|
|
47
|
+
const IID_IFILE_OPEN_DIALOG = guidBytes('d57c7288-d4ad-4768-be02-9d969532d960');
|
|
48
|
+
|
|
49
|
+
async function loadWin32DialogBindings() {
|
|
50
|
+
const koffi = (await import('koffi')).default;
|
|
51
|
+
const ole32 = koffi.load('ole32.dll');
|
|
52
|
+
const user32 = koffi.load('user32.dll');
|
|
53
|
+
const kernel32 = koffi.load('kernel32.dll');
|
|
54
|
+
const pointerSize = koffi.sizeof('void *');
|
|
55
|
+
const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32']);
|
|
56
|
+
const coUninitialize = ole32.func('__stdcall', 'CoUninitialize', 'void', []);
|
|
57
|
+
const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', [
|
|
58
|
+
'void *', 'void *', 'uint32', 'void *', 'void *',
|
|
59
|
+
]);
|
|
60
|
+
const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *']);
|
|
61
|
+
const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', []);
|
|
62
|
+
const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)');
|
|
63
|
+
const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)');
|
|
64
|
+
const protoSetTitle = koffi.proto('int32 __stdcall DshDialogSetTitle(void *self, str16 title)');
|
|
65
|
+
const protoGetResult = koffi.proto('int32 __stdcall DshDialogGetResult(void *self, _Out_ void **item)');
|
|
66
|
+
const protoGetDisplayName = koffi.proto('int32 __stdcall DshItemGetDisplayName(void *self, int32 form, _Out_ void **name)');
|
|
67
|
+
const protoRelease = koffi.proto('uint32 __stdcall DshComRelease(void *self)');
|
|
68
|
+
const method = (self, slot, proto) => {
|
|
69
|
+
const vtable = koffi.decode(self, 'void *');
|
|
70
|
+
const fn = koffi.decode(vtable, slot * pointerSize, 'void *');
|
|
71
|
+
return (...args) => koffi.call(fn, proto, self, ...args);
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
setThreadDpiAwareness: () => {
|
|
75
|
+
let setContext;
|
|
76
|
+
try {
|
|
77
|
+
setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr']);
|
|
78
|
+
} catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const context of DPI_AWARENESS_CONTEXTS) if (setContext(context) !== null) return;
|
|
82
|
+
},
|
|
83
|
+
coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED),
|
|
84
|
+
coUninitialize: () => {
|
|
85
|
+
coUninitialize();
|
|
86
|
+
},
|
|
87
|
+
currentThreadId: () => getCurrentThreadId(),
|
|
88
|
+
createFileDialog: () => {
|
|
89
|
+
const out = Buffer.alloc(pointerSize);
|
|
90
|
+
const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out);
|
|
91
|
+
if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`);
|
|
92
|
+
const dialog = koffi.decode(out, 'void *');
|
|
93
|
+
return {
|
|
94
|
+
setOptions: (options) => method(dialog, SLOT_SET_OPTIONS, protoSetOptions)(options),
|
|
95
|
+
setTitle: (title) => method(dialog, SLOT_SET_TITLE, protoSetTitle)(title),
|
|
96
|
+
show: () => method(dialog, SLOT_SHOW, protoShow)(null),
|
|
97
|
+
resultPath: () => {
|
|
98
|
+
const itemOut = [null];
|
|
99
|
+
const gotItem = method(dialog, SLOT_GET_RESULT, protoGetResult)(itemOut);
|
|
100
|
+
if (gotItem < 0) return { hr: gotItem };
|
|
101
|
+
const item = itemOut[0];
|
|
102
|
+
try {
|
|
103
|
+
const nameOut = [null];
|
|
104
|
+
const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut);
|
|
105
|
+
if (gotName < 0) return { hr: gotName };
|
|
106
|
+
const path = readUtf16(koffi, nameOut[0]);
|
|
107
|
+
coTaskMemFree(nameOut[0]);
|
|
108
|
+
return { hr: gotName, path };
|
|
109
|
+
} finally {
|
|
110
|
+
method(item, SLOT_RELEASE, protoRelease)();
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
release: () => {
|
|
114
|
+
method(dialog, SLOT_RELEASE, protoRelease)();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function check(hr, what) {
|
|
122
|
+
if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`);
|
|
123
|
+
return hr;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 一次模态文件选择对话:DPI 感知、STA 初始化、创建对话框、Show、取结果。 */
|
|
127
|
+
function runFileDialog(bindings, title, onShowing) {
|
|
128
|
+
bindings.setThreadDpiAwareness();
|
|
129
|
+
check(bindings.coInitializeSta(), 'CoInitializeEx');
|
|
130
|
+
try {
|
|
131
|
+
const dialog = bindings.createFileDialog();
|
|
132
|
+
try {
|
|
133
|
+
check(dialog.setOptions(FILE_DIALOG_OPTIONS), 'SetOptions');
|
|
134
|
+
check(dialog.setTitle(title), 'SetTitle');
|
|
135
|
+
onShowing(bindings.currentThreadId());
|
|
136
|
+
const shown = dialog.show();
|
|
137
|
+
if (shown === -2147023673) return null; // HRESULT_FROM_WIN32(ERROR_CANCELLED)
|
|
138
|
+
check(shown, 'Show');
|
|
139
|
+
const result = dialog.resultPath();
|
|
140
|
+
check(result.hr, 'GetResult');
|
|
141
|
+
return result.path;
|
|
142
|
+
} finally {
|
|
143
|
+
dialog.release();
|
|
144
|
+
}
|
|
145
|
+
} finally {
|
|
146
|
+
bindings.coUninitialize();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const title = process.env.DSH_DIALOG_TITLE ?? '';
|
|
151
|
+
if (title === '') throw new Error('dsh-auto-open-web worker: DSH_DIALOG_TITLE is required');
|
|
152
|
+
if (process.send === undefined) throw new Error('dsh-auto-open-web worker must run as a child process with an IPC channel');
|
|
153
|
+
const send = process.send.bind(process);
|
|
154
|
+
const post = (message) => {
|
|
155
|
+
send(message, () => {
|
|
156
|
+
if (process.connected) process.disconnect();
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
process.on('disconnect', () => process.exit(0));
|
|
160
|
+
(async () => {
|
|
161
|
+
try {
|
|
162
|
+
post({
|
|
163
|
+
kind: 'done',
|
|
164
|
+
path: runFileDialog(await loadWin32DialogBindings(), title, (threadId) => {
|
|
165
|
+
post({ kind: 'showing', threadId });
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
post({
|
|
170
|
+
kind: 'error',
|
|
171
|
+
message: error instanceof Error ? error.stack ?? error.message : String(error),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-auto-open-web",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Open the DSH Web GUI in an app-style WebView2 window on profile start, with a settings card for browser paths",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build:host": "dotnet publish host/DshAppWindow.csproj -c Release -o host-publish --nologo",
|
|
9
|
+
"prepack": "npm run build:host"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./lib/index.js",
|
|
13
|
+
"./client": "./lib/client.js",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"dsh": {
|
|
17
|
+
"bundle": {
|
|
18
|
+
"patch": "./cordis.patch.yml"
|
|
19
|
+
},
|
|
20
|
+
"client": {
|
|
21
|
+
"platform": "web",
|
|
22
|
+
"inject": [
|
|
23
|
+
"@deepseek-ai/dsh-client-connection",
|
|
24
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
25
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"lib/index.js",
|
|
31
|
+
"lib/client.js",
|
|
32
|
+
"lib/worker.cjs",
|
|
33
|
+
"lib/paths.js",
|
|
34
|
+
"lib/platform.js",
|
|
35
|
+
"lib/win32.js",
|
|
36
|
+
"lib/posix.js",
|
|
37
|
+
"host-publish",
|
|
38
|
+
"cordis.patch.yml",
|
|
39
|
+
"README.md",
|
|
40
|
+
"README.en.md"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
44
|
+
"koffi": "^3.1.4"
|
|
45
|
+
},
|
|
46
|
+
"license": "MIT"
|
|
47
|
+
}
|