@morlay/dsh-desktop-shell 0.0.1-alpha.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 +21 -0
- package/README.md +34 -0
- package/dist/appconfig-B4kozO2F.mjs +43 -0
- package/dist/appconfig.mjs +2 -0
- package/dist/dshhome-C_iyJzc8.mjs +26 -0
- package/dist/dshhome.mjs +2 -0
- package/dist/index.mjs +1849 -0
- package/dist/official-C8RFHQyT.mjs +170 -0
- package/dist/official.mjs +2 -0
- package/dist/preload-app.cjs +182 -0
- package/dist/profile-project-CEAT8PEX.mjs +101 -0
- package/dist/profile-project.mjs +2 -0
- package/dist/seed-Bg2S14hM.mjs +99 -0
- package/dist/seed.mjs +2 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1849 @@
|
|
|
1
|
+
import { r as loadAppConfig } from "./appconfig-B4kozO2F.mjs";
|
|
2
|
+
import { n as resolveDshHome } from "./dshhome-C_iyJzc8.mjs";
|
|
3
|
+
import { t as DESKTOP_HOST_PACKAGE } from "./official-C8RFHQyT.mjs";
|
|
4
|
+
import { a as runtimeOverrides, i as installProfile, o as workspaceWithOverrides, r as PROFILE_WORKSPACE_NAME } from "./profile-project-CEAT8PEX.mjs";
|
|
5
|
+
import { i as ensureSeedProfile, r as SEED_RUNTIME_DIR_NAME } from "./seed-Bg2S14hM.mjs";
|
|
6
|
+
import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { BrowserWindow, app, dialog, ipcMain, nativeTheme, protocol } from "electron";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { once } from "node:events";
|
|
12
|
+
import { Readable, Writable } from "node:stream";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
//#region src/ipc.ts
|
|
15
|
+
const DESKTOP_IPC = {
|
|
16
|
+
directoryPick: "dsh-desktop:directory-pick",
|
|
17
|
+
nativeThemeSet: "dsh-desktop:native-theme-set",
|
|
18
|
+
/** 桌面流载体:页面把 Gateway 的流请求交给主进程,主进程用标准 Request 喂宿主。 */
|
|
19
|
+
streamOpen: "dsh-desktop:stream-open",
|
|
20
|
+
streamCancel: "dsh-desktop:stream-cancel",
|
|
21
|
+
/** 逻辑流的上行项(客户端 → 宿主),主进程把它们写成宿主请求体的后续行。 */
|
|
22
|
+
streamUplink: "dsh-desktop:stream-uplink",
|
|
23
|
+
/** 上行结束:主进程据此结束宿主请求体。 */
|
|
24
|
+
streamUplinkEnd: "dsh-desktop:stream-uplink-end",
|
|
25
|
+
streamChunk: "dsh-desktop:stream-chunk",
|
|
26
|
+
streamEnd: "dsh-desktop:stream-end",
|
|
27
|
+
streamError: "dsh-desktop:stream-error",
|
|
28
|
+
/** 桌面快捷键:设备偏好的读写 / 录制状态(渲染进程 → 主进程)。 */
|
|
29
|
+
shortcutsGet: "dsh-desktop:shortcuts-get",
|
|
30
|
+
shortcutsEdit: "dsh-desktop:shortcuts-edit",
|
|
31
|
+
shortcutsRecording: "dsh-desktop:shortcuts-recording",
|
|
32
|
+
shortcutsCloseWindow: "dsh-desktop:shortcuts-close-window",
|
|
33
|
+
/** 命中的原生按键手势(主进程 → 渲染进程);渲染侧的 native keyboard 适配器消费。 */
|
|
34
|
+
shortcutsInput: "dsh-desktop:shortcuts-input",
|
|
35
|
+
/** 配置快照变化(主进程 → 渲染进程)。 */
|
|
36
|
+
shortcutsChanged: "dsh-desktop:shortcuts-changed"
|
|
37
|
+
};
|
|
38
|
+
/** preload 从渲染进程 argv 里读回 scheme 的参数名(主进程与 preload 共用一份)。 */
|
|
39
|
+
const DESKTOP_SCHEME_ARGUMENT = "--dsh-desktop-scheme";
|
|
40
|
+
/**
|
|
41
|
+
* 应用页面的自定义协议:取 app 工作区 package.json 的 name 派生,避免与官方桌面应用
|
|
42
|
+
* 共用同一个 scheme。去 scope 前缀、转小写、非法字符换成 `-`;派生不出合法 scheme 就报错。
|
|
43
|
+
*/
|
|
44
|
+
function desktopScheme(name) {
|
|
45
|
+
const bare = name.slice(name.lastIndexOf("/") + 1).toLowerCase().replaceAll(/[^a-z0-9+.-]/gu, "-");
|
|
46
|
+
if (!/^[a-z][a-z0-9+.-]*$/u.test(bare)) throw new Error(`dsh desktop: application name "${name}" does not yield a usable URL scheme (${bare})`);
|
|
47
|
+
return bare;
|
|
48
|
+
}
|
|
49
|
+
function assertDesktopSender(event, scheme, hostnames) {
|
|
50
|
+
const senderFrame = event.senderFrame;
|
|
51
|
+
if (senderFrame === null) throw new Error("dsh desktop: rejected IPC without a sender frame");
|
|
52
|
+
const url = new URL(senderFrame.url);
|
|
53
|
+
if (url.protocol !== `${scheme}:` || !hostnames.includes(url.hostname)) throw new Error("dsh desktop: rejected IPC from an unowned renderer");
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/directory-picker.ts
|
|
57
|
+
function installDesktopDirectoryPicker(getWindow, scheme) {
|
|
58
|
+
const pending = /* @__PURE__ */ new WeakMap();
|
|
59
|
+
ipcMain.handle(DESKTOP_IPC.directoryPick, async (event) => {
|
|
60
|
+
const window = getWindow();
|
|
61
|
+
if (window === void 0 || window.isDestroyed() || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error("dsh desktop: rejected directory picker from an unowned renderer");
|
|
62
|
+
assertDesktopSender(event, scheme, ["app"]);
|
|
63
|
+
const existing = pending.get(window);
|
|
64
|
+
if (existing !== void 0) return existing;
|
|
65
|
+
if (window.isMinimized()) window.restore();
|
|
66
|
+
window.show();
|
|
67
|
+
window.focus();
|
|
68
|
+
const result = dialog.showOpenDialog(window, { properties: ["openDirectory", "createDirectory"] }).then(({ canceled, filePaths }) => window.isDestroyed() || canceled ? null : filePaths[0] ?? null).finally(() => {
|
|
69
|
+
pending.delete(window);
|
|
70
|
+
});
|
|
71
|
+
pending.set(window, result);
|
|
72
|
+
return result;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** 桌面流的宿主路径:页面/主进程都用它(两侧共享一份,避免路径写两处)。 */
|
|
76
|
+
const DESKTOP_STREAM_PATH = "/.dsh/remote-stream";
|
|
77
|
+
/**
|
|
78
|
+
* 桌面流请求体的首行:定下这条逻辑流的 endpoint 与 payload,后续行都是上行项
|
|
79
|
+
* (客户端 → 宿主;`$events` 这类宿主自己的流被上游释放,不会读到它们)。
|
|
80
|
+
*/
|
|
81
|
+
function encodeDesktopStreamOpen(endpoint, payload) {
|
|
82
|
+
return `${JSON.stringify({
|
|
83
|
+
endpoint,
|
|
84
|
+
payload
|
|
85
|
+
})}\n`;
|
|
86
|
+
}
|
|
87
|
+
/** 桌面流请求体里的一道上行项。 */
|
|
88
|
+
function encodeDesktopStreamItem(item) {
|
|
89
|
+
return `${JSON.stringify(item)}\n`;
|
|
90
|
+
}
|
|
91
|
+
/** 单个 data 帧携带的最大原始字节数。 */
|
|
92
|
+
const DESKTOP_PIPE_CHUNK_BYTES = 65536;
|
|
93
|
+
const FRAME_MAGIC = 1146308659;
|
|
94
|
+
const FRAME_HEADER_BYTES = 13;
|
|
95
|
+
const MAX_CONTROL_PAYLOAD_BYTES = 1048576;
|
|
96
|
+
const REQUEST_FRAME_START = 1;
|
|
97
|
+
const REQUEST_FRAME_DATA = 2;
|
|
98
|
+
const REQUEST_FRAME_END = 3;
|
|
99
|
+
const REQUEST_FRAME_CANCEL = 4;
|
|
100
|
+
const RESPONSE_FRAME_START = 1;
|
|
101
|
+
const RESPONSE_FRAME_DATA = 2;
|
|
102
|
+
const RESPONSE_FRAME_END = 3;
|
|
103
|
+
const RESPONSE_FRAME_ERROR = 4;
|
|
104
|
+
function isRecord(value) {
|
|
105
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
106
|
+
}
|
|
107
|
+
function isHeaders(value) {
|
|
108
|
+
return Array.isArray(value) && value.every((header) => Array.isArray(header) && header.length === 2 && typeof header[0] === "string" && typeof header[1] === "string");
|
|
109
|
+
}
|
|
110
|
+
function assertStreamId(streamId) {
|
|
111
|
+
if (!Number.isInteger(streamId) || streamId < 1 || streamId > 4294967295) throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`);
|
|
112
|
+
}
|
|
113
|
+
function encodeFrame(type, streamId, payload, dataType, direction) {
|
|
114
|
+
assertStreamId(streamId);
|
|
115
|
+
const limit = type === dataType ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES;
|
|
116
|
+
if (payload.byteLength > limit) throw new Error(`dsh desktop: ${direction} pipe frame exceeds the ${String(limit)}-byte limit`);
|
|
117
|
+
const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength);
|
|
118
|
+
frame.writeUInt32BE(FRAME_MAGIC, 0);
|
|
119
|
+
frame.writeUInt8(type, 4);
|
|
120
|
+
frame.writeUInt32BE(streamId, 5);
|
|
121
|
+
frame.writeUInt32BE(payload.byteLength, 9);
|
|
122
|
+
payload.copy(frame, FRAME_HEADER_BYTES);
|
|
123
|
+
return frame;
|
|
124
|
+
}
|
|
125
|
+
function jsonPayload(type, streamId, value, dataType, direction) {
|
|
126
|
+
return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), "utf8"), dataType, direction);
|
|
127
|
+
}
|
|
128
|
+
/** 编码开启一个请求流的元数据帧(壳侧)。 */
|
|
129
|
+
function encodeDesktopRequestStart(streamId, request) {
|
|
130
|
+
return jsonPayload(REQUEST_FRAME_START, streamId, request, REQUEST_FRAME_DATA, "request");
|
|
131
|
+
}
|
|
132
|
+
/** 编码一个受限的请求体原始分片(壳侧)。 */
|
|
133
|
+
function encodeDesktopRequestData(streamId, data) {
|
|
134
|
+
return encodeFrame(REQUEST_FRAME_DATA, streamId, Buffer.from(data), REQUEST_FRAME_DATA, "request");
|
|
135
|
+
}
|
|
136
|
+
/** 编码请求体正常结束(壳侧)。 */
|
|
137
|
+
function encodeDesktopRequestEnd(streamId) {
|
|
138
|
+
return encodeFrame(REQUEST_FRAME_END, streamId, Buffer.alloc(0), REQUEST_FRAME_DATA, "request");
|
|
139
|
+
}
|
|
140
|
+
/** 编码取消一个请求及其响应(壳侧)。 */
|
|
141
|
+
function encodeDesktopRequestCancel(streamId) {
|
|
142
|
+
return encodeFrame(REQUEST_FRAME_CANCEL, streamId, Buffer.alloc(0), REQUEST_FRAME_DATA, "request");
|
|
143
|
+
}
|
|
144
|
+
function frameLength(buffer, dataType, direction) {
|
|
145
|
+
if (buffer.byteLength < FRAME_HEADER_BYTES) return void 0;
|
|
146
|
+
if (buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error(`dsh desktop: invalid ${direction} frame marker`);
|
|
147
|
+
const type = buffer.readUInt8(4);
|
|
148
|
+
const streamId = buffer.readUInt32BE(5);
|
|
149
|
+
const payloadLength = buffer.readUInt32BE(9);
|
|
150
|
+
assertStreamId(streamId);
|
|
151
|
+
const limit = type === dataType ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES;
|
|
152
|
+
if (payloadLength > limit) throw new Error(`dsh desktop: ${direction} frame exceeds the ${String(limit)}-byte limit`);
|
|
153
|
+
const total = FRAME_HEADER_BYTES + payloadLength;
|
|
154
|
+
return buffer.byteLength < total ? void 0 : total;
|
|
155
|
+
}
|
|
156
|
+
function parseJson(payload, subject) {
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(payload.toString("utf8"));
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw new Error(`dsh desktop: ${subject} payload is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function assertEmpty(payload, subject) {
|
|
164
|
+
if (payload.byteLength !== 0) throw new Error(`dsh desktop: ${subject} frame carried a payload`);
|
|
165
|
+
}
|
|
166
|
+
/** 增量解码壳侧从响应管道收到的响应帧。 */
|
|
167
|
+
var DesktopHostResponseDecoder = class {
|
|
168
|
+
buffer = Buffer.alloc(0);
|
|
169
|
+
/** 追加字节并返回所有已完整的响应帧。 */
|
|
170
|
+
push(chunk) {
|
|
171
|
+
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
172
|
+
const frames = [];
|
|
173
|
+
for (;;) {
|
|
174
|
+
const frame = this.next();
|
|
175
|
+
if (frame === void 0) return frames;
|
|
176
|
+
frames.push(frame);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** EOF 落在帧中间时报错。 */
|
|
180
|
+
finish() {
|
|
181
|
+
if (this.buffer.byteLength !== 0) throw new Error("dsh desktop: response pipe ended inside a frame");
|
|
182
|
+
}
|
|
183
|
+
next() {
|
|
184
|
+
const total = frameLength(this.buffer, RESPONSE_FRAME_DATA, "response");
|
|
185
|
+
if (total === void 0) return void 0;
|
|
186
|
+
const type = this.buffer.readUInt8(4);
|
|
187
|
+
const streamId = this.buffer.readUInt32BE(5);
|
|
188
|
+
const payload = this.buffer.subarray(FRAME_HEADER_BYTES, total);
|
|
189
|
+
this.buffer = this.buffer.subarray(total);
|
|
190
|
+
switch (type) {
|
|
191
|
+
case RESPONSE_FRAME_START: return {
|
|
192
|
+
type: "start",
|
|
193
|
+
streamId,
|
|
194
|
+
...this.parseStart(payload)
|
|
195
|
+
};
|
|
196
|
+
case RESPONSE_FRAME_DATA: return {
|
|
197
|
+
type: "data",
|
|
198
|
+
streamId,
|
|
199
|
+
data: Buffer.from(payload)
|
|
200
|
+
};
|
|
201
|
+
case RESPONSE_FRAME_END:
|
|
202
|
+
assertEmpty(payload, "response end");
|
|
203
|
+
return {
|
|
204
|
+
type: "end",
|
|
205
|
+
streamId
|
|
206
|
+
};
|
|
207
|
+
case RESPONSE_FRAME_ERROR: return {
|
|
208
|
+
type: "error",
|
|
209
|
+
streamId,
|
|
210
|
+
...this.parseError(payload)
|
|
211
|
+
};
|
|
212
|
+
default: throw new Error(`dsh desktop: unknown response frame type ${String(type)}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
parseStart(payload) {
|
|
216
|
+
const value = parseJson(payload, "response start");
|
|
217
|
+
if (!isRecord(value) || !Number.isInteger(value.status) || value.status < 100 || value.status > 599 || !isHeaders(value.headers) || typeof value.hasBody !== "boolean") throw new Error("dsh desktop: invalid response start payload");
|
|
218
|
+
return {
|
|
219
|
+
status: value.status,
|
|
220
|
+
headers: value.headers,
|
|
221
|
+
hasBody: value.hasBody
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
parseError(payload) {
|
|
225
|
+
const value = parseJson(payload, "response error");
|
|
226
|
+
if (!isRecord(value) || typeof value.message !== "string") throw new Error("dsh desktop: invalid response error payload");
|
|
227
|
+
return { message: value.message };
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/** IPC 事件校验(壳侧)。 */
|
|
231
|
+
function isDesktopHostEvent(value) {
|
|
232
|
+
if (!isRecord(value)) return false;
|
|
233
|
+
if (value.type === "ready") return value.protocolVersion === 1;
|
|
234
|
+
if (value.type === "fatal") return typeof value.message === "string" && (value.diagnostic === void 0 || typeof value.diagnostic === "string");
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/host-process.ts
|
|
239
|
+
function errorOf(reason, fallback) {
|
|
240
|
+
return reason instanceof Error ? reason : new Error(fallback);
|
|
241
|
+
}
|
|
242
|
+
async function exitsWithin(exit, milliseconds) {
|
|
243
|
+
let timer;
|
|
244
|
+
const timeout = new Promise((resolve) => {
|
|
245
|
+
timer = setTimeout(() => {
|
|
246
|
+
resolve(false);
|
|
247
|
+
}, milliseconds);
|
|
248
|
+
timer.unref();
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
return await Promise.race([exit.then(() => true), timeout]);
|
|
252
|
+
} finally {
|
|
253
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** 桌面 host 子进程:字节管道上的请求/响应载体(FD 3/4)+ Node IPC 生命周期。 */
|
|
257
|
+
var DesktopHostProcess = class {
|
|
258
|
+
node;
|
|
259
|
+
runtimeDir;
|
|
260
|
+
projectDir;
|
|
261
|
+
inspectPort;
|
|
262
|
+
options;
|
|
263
|
+
child;
|
|
264
|
+
requestPipe;
|
|
265
|
+
responsePipe;
|
|
266
|
+
responseDecoder = new DesktopHostResponseDecoder();
|
|
267
|
+
requestWriteTail = Promise.resolve();
|
|
268
|
+
nextStreamId = 1;
|
|
269
|
+
pending = /* @__PURE__ */ new Map();
|
|
270
|
+
blockedResponses = /* @__PURE__ */ new Set();
|
|
271
|
+
readyResolve;
|
|
272
|
+
readyReject;
|
|
273
|
+
readyPromise = new Promise((resolve, reject) => {
|
|
274
|
+
this.readyResolve = resolve;
|
|
275
|
+
this.readyReject = reject;
|
|
276
|
+
});
|
|
277
|
+
exitPromise;
|
|
278
|
+
stderr = "";
|
|
279
|
+
failureReported = false;
|
|
280
|
+
stopping = false;
|
|
281
|
+
constructor(node, runtimeDir, projectDir, inspectPort, options = {}) {
|
|
282
|
+
this.node = node;
|
|
283
|
+
this.runtimeDir = runtimeDir;
|
|
284
|
+
this.projectDir = projectDir;
|
|
285
|
+
this.inspectPort = inspectPort;
|
|
286
|
+
this.options = options;
|
|
287
|
+
}
|
|
288
|
+
/** 启动子进程一次;`ready` 之后才解析。 */
|
|
289
|
+
async start() {
|
|
290
|
+
if (this.child !== void 0) return this.readyPromise;
|
|
291
|
+
const entry = join(this.runtimeDir, "node_modules", ...DESKTOP_HOST_PACKAGE.split("/"), "lib", "index.js");
|
|
292
|
+
const primaryRuntime = this.options.primaryRuntime ?? join(this.runtimeDir, "..", "runtime", "primary-runtime");
|
|
293
|
+
const packageManager = this.options.packageManager;
|
|
294
|
+
const args = [
|
|
295
|
+
"--expose-internals",
|
|
296
|
+
...this.options.processTitle === void 0 ? [] : [`--title=${this.options.processTitle}`],
|
|
297
|
+
...this.inspectPort === void 0 ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`],
|
|
298
|
+
...this.options.nodeArgs ?? [],
|
|
299
|
+
entry,
|
|
300
|
+
this.runtimeDir,
|
|
301
|
+
this.projectDir,
|
|
302
|
+
primaryRuntime,
|
|
303
|
+
...packageManager === void 0 ? [] : [packageManager.pnpm, packageManager.nodeBin]
|
|
304
|
+
];
|
|
305
|
+
const env = {
|
|
306
|
+
...Object.fromEntries(Object.entries(process.env).filter(([name]) => name !== "NODE_OPTIONS" && !name.startsWith("DSH_DESKTOP_") && !/^(?:npm|pnpm|corepack)_/iu.test(name))),
|
|
307
|
+
...this.options.extraEnv
|
|
308
|
+
};
|
|
309
|
+
const child = (this.options.spawn ?? spawn)(this.node, args, {
|
|
310
|
+
cwd: this.projectDir,
|
|
311
|
+
env,
|
|
312
|
+
stdio: [
|
|
313
|
+
"ignore",
|
|
314
|
+
"pipe",
|
|
315
|
+
"pipe",
|
|
316
|
+
"pipe",
|
|
317
|
+
"pipe",
|
|
318
|
+
"ipc"
|
|
319
|
+
]
|
|
320
|
+
});
|
|
321
|
+
const requestPipe = child.stdio[3];
|
|
322
|
+
const responsePipe = child.stdio[4];
|
|
323
|
+
if (!(requestPipe instanceof Writable) || !(responsePipe instanceof Readable)) {
|
|
324
|
+
child.kill("SIGTERM");
|
|
325
|
+
throw new Error("dsh desktop host did not expose the required byte pipes and IPC channel");
|
|
326
|
+
}
|
|
327
|
+
this.child = child;
|
|
328
|
+
this.requestPipe = requestPipe;
|
|
329
|
+
this.responsePipe = responsePipe;
|
|
330
|
+
child.stderr?.setEncoding("utf8");
|
|
331
|
+
child.stderr?.on("data", (chunk) => {
|
|
332
|
+
this.stderr = (this.stderr + chunk).slice(-65536);
|
|
333
|
+
process.stderr.write(`[dsh-host] ${chunk}`);
|
|
334
|
+
});
|
|
335
|
+
child.stdout?.pipe(process.stdout);
|
|
336
|
+
responsePipe.on("data", (chunk) => {
|
|
337
|
+
this.acceptResponseBytes(chunk);
|
|
338
|
+
});
|
|
339
|
+
responsePipe.once("end", () => {
|
|
340
|
+
try {
|
|
341
|
+
this.responseDecoder.finish();
|
|
342
|
+
} catch (error) {
|
|
343
|
+
this.fail(errorOf(error, "dsh desktop host response pipe failed"));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.fail(/* @__PURE__ */ new Error("dsh desktop host response pipe ended"));
|
|
347
|
+
});
|
|
348
|
+
requestPipe.once("error", (error) => {
|
|
349
|
+
this.fail(error);
|
|
350
|
+
});
|
|
351
|
+
responsePipe.once("error", (error) => {
|
|
352
|
+
this.fail(error);
|
|
353
|
+
});
|
|
354
|
+
child.on("message", (message) => {
|
|
355
|
+
if (!isDesktopHostEvent(message)) {
|
|
356
|
+
this.fail(/* @__PURE__ */ new Error("dsh desktop host sent an invalid IPC event"));
|
|
357
|
+
this.killChild("SIGTERM");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (message.type === "fatal") {
|
|
361
|
+
if (message.diagnostic !== void 0) process.stderr.write(`[dsh-host] ${message.diagnostic}\n`);
|
|
362
|
+
this.fail(new Error(message.message));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.readyResolve({ protocolVersion: message.protocolVersion });
|
|
366
|
+
});
|
|
367
|
+
child.once("error", (error) => {
|
|
368
|
+
this.fail(error);
|
|
369
|
+
});
|
|
370
|
+
this.exitPromise = new Promise((resolve) => {
|
|
371
|
+
child.once("close", (code) => {
|
|
372
|
+
const suffix = this.stderr.trim() === "" ? "" : `: ${this.stderr.trim()}`;
|
|
373
|
+
if (code !== 0 && code !== null) this.fail(/* @__PURE__ */ new Error(`dsh desktop host exited with ${String(code)}${suffix}`));
|
|
374
|
+
else this.fail(/* @__PURE__ */ new Error(`dsh desktop host stopped${suffix}`));
|
|
375
|
+
resolve();
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
return this.readyPromise;
|
|
379
|
+
}
|
|
380
|
+
/** 把一个应用协议请求(`<scheme>://app/*`)交给子进程,响应体边收边出。 */
|
|
381
|
+
async fetch(request) {
|
|
382
|
+
await this.start();
|
|
383
|
+
const child = this.child;
|
|
384
|
+
if (child === void 0 || !child.connected || this.requestPipe === void 0) throw new Error("dsh desktop host is unavailable");
|
|
385
|
+
if (this.nextStreamId > 4294967295) throw new Error("dsh desktop host exhausted its request stream ids");
|
|
386
|
+
const streamId = this.nextStreamId++;
|
|
387
|
+
const method = request.method.toUpperCase();
|
|
388
|
+
const hasBody = method !== "GET" && method !== "HEAD" && request.body !== null;
|
|
389
|
+
return new Promise((resolve, reject) => {
|
|
390
|
+
const pending = {
|
|
391
|
+
resolve,
|
|
392
|
+
reject,
|
|
393
|
+
responseStarted: false,
|
|
394
|
+
uploadOpen: hasBody
|
|
395
|
+
};
|
|
396
|
+
const abort = () => {
|
|
397
|
+
if (!this.pending.has(streamId)) return;
|
|
398
|
+
const error = errorOf(request.signal.reason, "request aborted");
|
|
399
|
+
pending.uploadOpen = false;
|
|
400
|
+
pending.requestReader?.cancel(error).catch(() => void 0);
|
|
401
|
+
this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError) => {
|
|
402
|
+
this.fail(errorOf(pipeError, "dsh desktop request pipe failed"));
|
|
403
|
+
});
|
|
404
|
+
if (pending.controller === void 0) pending.reject(error);
|
|
405
|
+
else pending.controller.error(error);
|
|
406
|
+
this.finishPending(streamId, false);
|
|
407
|
+
};
|
|
408
|
+
if (request.signal.aborted) {
|
|
409
|
+
reject(errorOf(request.signal.reason, "request aborted"));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
413
|
+
pending.removeAbort = () => {
|
|
414
|
+
request.signal.removeEventListener("abort", abort);
|
|
415
|
+
};
|
|
416
|
+
this.pending.set(streamId, pending);
|
|
417
|
+
this.pumpRequest(streamId, request, hasBody).catch((error) => {
|
|
418
|
+
this.failPending(streamId, errorOf(error, "dsh desktop request upload failed"));
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/** 请求优雅退出,再等子进程收尾。 */
|
|
423
|
+
async stop() {
|
|
424
|
+
const child = this.child;
|
|
425
|
+
if (child === void 0) return;
|
|
426
|
+
this.stopping = true;
|
|
427
|
+
this.blockedResponses.clear();
|
|
428
|
+
this.responsePipe?.resume();
|
|
429
|
+
if (child.connected) this.send({ type: "shutdown" });
|
|
430
|
+
this.requestPipe?.destroy();
|
|
431
|
+
const exited = this.exitPromise ?? Promise.resolve();
|
|
432
|
+
if (!await exitsWithin(exited, 1e4)) this.killChild("SIGTERM");
|
|
433
|
+
if (!await exitsWithin(exited, 5e3)) {
|
|
434
|
+
this.killChild("SIGKILL");
|
|
435
|
+
if (!await exitsWithin(exited, 5e3)) throw new Error("dsh desktop host did not exit after SIGKILL");
|
|
436
|
+
}
|
|
437
|
+
this.child = void 0;
|
|
438
|
+
this.requestPipe = void 0;
|
|
439
|
+
this.responsePipe = void 0;
|
|
440
|
+
}
|
|
441
|
+
async pumpRequest(streamId, request, hasBody) {
|
|
442
|
+
await this.enqueueRequestFrame(encodeDesktopRequestStart(streamId, {
|
|
443
|
+
url: request.url,
|
|
444
|
+
method: request.method.toUpperCase(),
|
|
445
|
+
headers: [...request.headers.entries()],
|
|
446
|
+
hasBody
|
|
447
|
+
}));
|
|
448
|
+
if (!hasBody) return;
|
|
449
|
+
const body = request.body;
|
|
450
|
+
if (body === null) throw new Error("dsh desktop request body disappeared before upload");
|
|
451
|
+
const reader = body.getReader();
|
|
452
|
+
const pending = this.pending.get(streamId);
|
|
453
|
+
if (pending === void 0) {
|
|
454
|
+
await reader.cancel();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
pending.requestReader = reader;
|
|
458
|
+
try {
|
|
459
|
+
for (;;) {
|
|
460
|
+
const next = await reader.read();
|
|
461
|
+
if (next.done) break;
|
|
462
|
+
for (let offset = 0; offset < next.value.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) {
|
|
463
|
+
if (!this.pending.has(streamId)) return;
|
|
464
|
+
await this.enqueueRequestFrame(encodeDesktopRequestData(streamId, next.value.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES)));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const live = this.pending.get(streamId);
|
|
468
|
+
if (live !== void 0) {
|
|
469
|
+
await this.enqueueRequestFrame(encodeDesktopRequestEnd(streamId));
|
|
470
|
+
live.uploadOpen = false;
|
|
471
|
+
}
|
|
472
|
+
} finally {
|
|
473
|
+
reader.releaseLock();
|
|
474
|
+
const live = this.pending.get(streamId);
|
|
475
|
+
if (live?.requestReader === reader) delete live.requestReader;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
enqueueRequestFrame(frame) {
|
|
479
|
+
const write = this.requestWriteTail.then(async () => {
|
|
480
|
+
const pipe = this.requestPipe;
|
|
481
|
+
if (pipe === void 0 || pipe.destroyed) throw new Error("dsh desktop host request pipe is unavailable");
|
|
482
|
+
if (!pipe.write(frame)) await once(pipe, "drain");
|
|
483
|
+
});
|
|
484
|
+
this.requestWriteTail = write.catch(() => void 0);
|
|
485
|
+
return write;
|
|
486
|
+
}
|
|
487
|
+
send(message) {
|
|
488
|
+
const child = this.child;
|
|
489
|
+
if (child === void 0 || !child.connected) throw new Error("dsh desktop host IPC is unavailable");
|
|
490
|
+
child.send(message, (error) => {
|
|
491
|
+
if (error !== null) this.fail(error);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
acceptResponseBytes(chunk) {
|
|
495
|
+
try {
|
|
496
|
+
for (const frame of this.responseDecoder.push(chunk)) this.handleResponseFrame(frame);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
this.fail(errorOf(error, "dsh desktop host response pipe failed"));
|
|
499
|
+
this.killChild("SIGTERM");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
handleResponseFrame(frame) {
|
|
503
|
+
const pending = this.pending.get(frame.streamId);
|
|
504
|
+
if (pending === void 0) {
|
|
505
|
+
if (frame.streamId >= this.nextStreamId) throw new Error(`dsh desktop host responded for unknown stream ${String(frame.streamId)}`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
switch (frame.type) {
|
|
509
|
+
case "start": {
|
|
510
|
+
if (pending.responseStarted) throw new Error(`dsh desktop host started stream ${String(frame.streamId)} twice`);
|
|
511
|
+
pending.responseStarted = true;
|
|
512
|
+
let body = null;
|
|
513
|
+
if (frame.hasBody) body = new ReadableStream({
|
|
514
|
+
start: (controller) => {
|
|
515
|
+
pending.controller = controller;
|
|
516
|
+
},
|
|
517
|
+
pull: () => {
|
|
518
|
+
this.blockedResponses.delete(frame.streamId);
|
|
519
|
+
this.resumeResponsePipe();
|
|
520
|
+
},
|
|
521
|
+
cancel: (reason) => {
|
|
522
|
+
this.cancelResponse(frame.streamId, reason);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
pending.resolve(new Response(body, {
|
|
526
|
+
status: frame.status,
|
|
527
|
+
headers: new Headers(frame.headers.map(([name, value]) => [name, value]))
|
|
528
|
+
}));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
case "data": {
|
|
532
|
+
const controller = pending.controller;
|
|
533
|
+
if (!pending.responseStarted || controller === void 0) throw new Error(`dsh desktop host sent body data before a body start for stream ${String(frame.streamId)}`);
|
|
534
|
+
controller.enqueue(frame.data);
|
|
535
|
+
if ((controller.desiredSize ?? 0) <= 0) {
|
|
536
|
+
this.blockedResponses.add(frame.streamId);
|
|
537
|
+
this.responsePipe?.pause();
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
case "end":
|
|
542
|
+
if (!pending.responseStarted) throw new Error(`dsh desktop host ended stream ${String(frame.streamId)} before its response start`);
|
|
543
|
+
pending.controller?.close();
|
|
544
|
+
this.finishPending(frame.streamId, true);
|
|
545
|
+
return;
|
|
546
|
+
case "error":
|
|
547
|
+
this.failPending(frame.streamId, new Error(frame.message));
|
|
548
|
+
return;
|
|
549
|
+
default: return;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
cancelResponse(streamId, reason) {
|
|
553
|
+
const pending = this.pending.get(streamId);
|
|
554
|
+
if (pending === void 0) return;
|
|
555
|
+
pending.uploadOpen = false;
|
|
556
|
+
pending.requestReader?.cancel(reason).catch(() => void 0);
|
|
557
|
+
this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error) => {
|
|
558
|
+
this.fail(errorOf(error, "dsh desktop request pipe failed"));
|
|
559
|
+
});
|
|
560
|
+
this.finishPending(streamId, false);
|
|
561
|
+
}
|
|
562
|
+
failPending(streamId, error) {
|
|
563
|
+
const pending = this.pending.get(streamId);
|
|
564
|
+
if (pending === void 0) return;
|
|
565
|
+
pending.uploadOpen = false;
|
|
566
|
+
pending.requestReader?.cancel(error).catch(() => void 0);
|
|
567
|
+
if (pending.controller === void 0) pending.reject(error);
|
|
568
|
+
else pending.controller.error(error);
|
|
569
|
+
this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError) => {
|
|
570
|
+
this.fail(errorOf(pipeError, "dsh desktop request pipe failed"));
|
|
571
|
+
});
|
|
572
|
+
this.finishPending(streamId, false);
|
|
573
|
+
}
|
|
574
|
+
finishPending(streamId, cancelOpenUpload) {
|
|
575
|
+
const pending = this.pending.get(streamId);
|
|
576
|
+
if (pending === void 0) return;
|
|
577
|
+
if (cancelOpenUpload && pending.uploadOpen) {
|
|
578
|
+
pending.uploadOpen = false;
|
|
579
|
+
pending.requestReader?.cancel().catch(() => void 0);
|
|
580
|
+
this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error) => {
|
|
581
|
+
this.fail(errorOf(error, "dsh desktop request pipe failed"));
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
pending.removeAbort?.();
|
|
585
|
+
this.pending.delete(streamId);
|
|
586
|
+
this.blockedResponses.delete(streamId);
|
|
587
|
+
this.resumeResponsePipe();
|
|
588
|
+
}
|
|
589
|
+
resumeResponsePipe() {
|
|
590
|
+
if (this.blockedResponses.size === 0) this.responsePipe?.resume();
|
|
591
|
+
}
|
|
592
|
+
killChild(signal) {
|
|
593
|
+
const child = this.child;
|
|
594
|
+
if (child === void 0 || child.pid === void 0) return;
|
|
595
|
+
if (process.platform !== "win32") try {
|
|
596
|
+
process.kill(-child.pid, signal);
|
|
597
|
+
return;
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if (error.code !== "ESRCH") throw error;
|
|
600
|
+
}
|
|
601
|
+
child.kill(signal);
|
|
602
|
+
}
|
|
603
|
+
fail(error) {
|
|
604
|
+
if (!this.stopping) this.readyReject(error);
|
|
605
|
+
if (!this.failureReported && !this.stopping) {
|
|
606
|
+
this.failureReported = true;
|
|
607
|
+
try {
|
|
608
|
+
this.options.onFailure?.(error);
|
|
609
|
+
} catch (listenerError) {
|
|
610
|
+
console.error(listenerError);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const closing = this.stopping;
|
|
614
|
+
for (const pending of this.pending.values()) {
|
|
615
|
+
pending.requestReader?.cancel(error).catch(() => void 0);
|
|
616
|
+
if (closing) pending.resolve(new Response("desktop host is shutting down", { status: 503 }));
|
|
617
|
+
else if (pending.controller === void 0) pending.reject(error);
|
|
618
|
+
else pending.controller.error(error);
|
|
619
|
+
pending.removeAbort?.();
|
|
620
|
+
}
|
|
621
|
+
this.pending.clear();
|
|
622
|
+
this.blockedResponses.clear();
|
|
623
|
+
this.responsePipe?.resume();
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region ../../../vendor/deepseek-harness/packages/util/values/lib/index.js
|
|
628
|
+
/** Duplicate-install-safe JSON and immutable-value helpers. @module @deepseek-ai/dsh-util-values */
|
|
629
|
+
/**
|
|
630
|
+
* Mark an unreachable closed-union branch.
|
|
631
|
+
* @param value - impossible value; an unhandled typed variant fails at the call site.
|
|
632
|
+
* @param context - optional switch-site label included in the failure message.
|
|
633
|
+
* @returns never; a runtime value that escaped its type always throws.
|
|
634
|
+
*/
|
|
635
|
+
function assertNever(value, context) {
|
|
636
|
+
const rendered = JSON.stringify(value) ?? String(value);
|
|
637
|
+
throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
|
|
638
|
+
}
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region ../../../vendor/deepseek-harness/packages/util/crypto/lib/index.js
|
|
641
|
+
/**
|
|
642
|
+
* Random v4 UUID, minted from `crypto.getRandomValues`.
|
|
643
|
+
* @returns the UUID string.
|
|
644
|
+
*/
|
|
645
|
+
function randomUUID() {
|
|
646
|
+
const bytes = globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
647
|
+
const hex = Array.from(bytes, (byte, index) => {
|
|
648
|
+
return (index === 6 ? byte & 15 | 64 : index === 8 ? byte & 63 | 128 : byte).toString(16).padStart(2, "0");
|
|
649
|
+
}).join("");
|
|
650
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
651
|
+
}
|
|
652
|
+
//#endregion
|
|
653
|
+
//#region ../../../vendor/deepseek-harness/packages/client/shortcuts/lib/protocol.js
|
|
654
|
+
const keyNames = {
|
|
655
|
+
Slash: "/",
|
|
656
|
+
Comma: ",",
|
|
657
|
+
Period: ".",
|
|
658
|
+
Backslash: "\\",
|
|
659
|
+
Backquote: "`",
|
|
660
|
+
Minus: "-",
|
|
661
|
+
Equal: "=",
|
|
662
|
+
BracketLeft: "[",
|
|
663
|
+
BracketRight: "]",
|
|
664
|
+
Semicolon: ";",
|
|
665
|
+
Quote: "'",
|
|
666
|
+
Enter: "Enter",
|
|
667
|
+
Escape: "Esc",
|
|
668
|
+
Space: "Space",
|
|
669
|
+
Tab: "Tab",
|
|
670
|
+
Backspace: "Backspace",
|
|
671
|
+
Delete: "Delete",
|
|
672
|
+
ArrowUp: "↑",
|
|
673
|
+
ArrowDown: "↓",
|
|
674
|
+
ArrowLeft: "←",
|
|
675
|
+
ArrowRight: "→"
|
|
676
|
+
};
|
|
677
|
+
const modifierOrder = [
|
|
678
|
+
"control",
|
|
679
|
+
"alt",
|
|
680
|
+
"shift",
|
|
681
|
+
"meta"
|
|
682
|
+
];
|
|
683
|
+
/**
|
|
684
|
+
* Expand logical modifiers, deduplicate, and validate the physical code.
|
|
685
|
+
* @param binding - declared binding.
|
|
686
|
+
* @param platform - receiving device platform.
|
|
687
|
+
* @returns canonical binding; unsupported codes throw during registration.
|
|
688
|
+
*/
|
|
689
|
+
function normalizeBinding(binding, platform) {
|
|
690
|
+
const codes = [binding.code, ...binding.secondCode === void 0 ? [] : [binding.secondCode]];
|
|
691
|
+
codes.sort();
|
|
692
|
+
for (const code of codes) if (!/^(Key[A-Z]|Digit[0-9]|F([1-9]|1[0-9]|2[0-4]))$/u.test(code) && !Object.hasOwn(keyNames, code)) throw new Error(`Unsupported shortcut code: ${code}`);
|
|
693
|
+
if (codes.length === 2 && codes[0] === codes[1]) throw new Error("Shortcut keys must be distinct");
|
|
694
|
+
const modifiers = new Set(binding.modifiers.map((value) => value === "primary" ? platform === "macos" ? "meta" : "control" : value));
|
|
695
|
+
return {
|
|
696
|
+
code: codes[0],
|
|
697
|
+
...codes[1] === void 0 ? {} : { secondCode: codes[1] },
|
|
698
|
+
modifiers: modifierOrder.filter((value) => modifiers.has(value))
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Produce an exact-match index from a normalized binding.
|
|
703
|
+
* @param binding - normalized physical key and modifiers.
|
|
704
|
+
* @returns stable index used for both matching and conflict checks.
|
|
705
|
+
*/
|
|
706
|
+
function bindingKey(binding) {
|
|
707
|
+
const codes = binding.secondCode === void 0 ? [binding.code] : [binding.code, binding.secondCode].sort();
|
|
708
|
+
return [...binding.modifiers, ...codes].join("+");
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Check Web combinations: Windows and macOS also admit any three or four modifiers; Linux retains the limited set.
|
|
712
|
+
* @param binding - normalized candidate.
|
|
713
|
+
* @param platform - receiving device platform.
|
|
714
|
+
* @returns whether this combination is admitted; admission does not guarantee browser or system delivery.
|
|
715
|
+
*/
|
|
716
|
+
function isWebBindingAllowed(binding, platform) {
|
|
717
|
+
if (binding.secondCode !== void 0) return false;
|
|
718
|
+
if (platform === "windows" || platform === "macos") {
|
|
719
|
+
if (binding.modifiers.length >= 3) return true;
|
|
720
|
+
const primary = platform === "macos" ? "meta" : "control";
|
|
721
|
+
if (binding.modifiers.length === 1 && (["Comma", "Backslash"].includes(binding.code) && binding.modifiers[0] === primary || binding.code === "Backquote" && binding.modifiers[0] === "control")) return true;
|
|
722
|
+
if (binding.modifiers.length === 2 && binding.modifiers.includes(primary) && (binding.modifiers.includes("alt") || binding.modifiers.includes("shift"))) return true;
|
|
723
|
+
}
|
|
724
|
+
return [
|
|
725
|
+
{
|
|
726
|
+
code: "Slash",
|
|
727
|
+
modifiers: ["primary"]
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
code: "Comma",
|
|
731
|
+
modifiers: ["primary", "shift"]
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
code: "Period",
|
|
735
|
+
modifiers: ["primary", "shift"]
|
|
736
|
+
}
|
|
737
|
+
].some((candidate) => bindingKey(binding) === bindingKey(normalizeBinding(candidate, platform)));
|
|
738
|
+
}
|
|
739
|
+
/** Validated preference documents and deterministic conflict resolution, without browser dependencies. */
|
|
740
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
741
|
+
const commandPattern = /^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/u;
|
|
742
|
+
/**
|
|
743
|
+
* Validate JSON binding fields before normalization; unknown fields are rejected to prevent lossy rewrites.
|
|
744
|
+
* @param value - file or IPC input.
|
|
745
|
+
* @returns a binding with a supported physical code, or null for explicit removal.
|
|
746
|
+
*/
|
|
747
|
+
function parseBinding(value) {
|
|
748
|
+
if (value === null) return null;
|
|
749
|
+
if (!record(value) || Object.keys(value).some((key) => key !== "code" && key !== "secondCode" && key !== "modifiers") || typeof value.code !== "string" || !Array.isArray(value.modifiers) || Object.hasOwn(value, "secondCode") && typeof value.secondCode !== "string" || !value.modifiers.every((modifier) => typeof modifier === "string" && [
|
|
750
|
+
"primary",
|
|
751
|
+
"control",
|
|
752
|
+
"alt",
|
|
753
|
+
"shift",
|
|
754
|
+
"meta"
|
|
755
|
+
].includes(modifier))) throw new Error("Invalid shortcut binding");
|
|
756
|
+
const binding = {
|
|
757
|
+
code: value.code,
|
|
758
|
+
modifiers: value.modifiers,
|
|
759
|
+
...typeof value.secondCode === "string" ? { secondCode: value.secondCode } : {}
|
|
760
|
+
};
|
|
761
|
+
normalizeBinding(binding, "windows");
|
|
762
|
+
return binding;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Decode the complete document while preserving dormant command overrides.
|
|
766
|
+
* @param raw - stored JSON, or null for a missing document.
|
|
767
|
+
* @returns the accepted document or a classified read failure.
|
|
768
|
+
*/
|
|
769
|
+
function parseShortcutDocument(raw) {
|
|
770
|
+
if (raw === null) return {
|
|
771
|
+
schemaVersion: 1,
|
|
772
|
+
profiles: {}
|
|
773
|
+
};
|
|
774
|
+
try {
|
|
775
|
+
const value = JSON.parse(raw);
|
|
776
|
+
if (!record(value)) return "invalid";
|
|
777
|
+
if (typeof value.schemaVersion === "number" && value.schemaVersion > 2) return "future";
|
|
778
|
+
if (value.schemaVersion !== 1 && value.schemaVersion !== 2 || !record(value.profiles) || Object.keys(value).some((key) => key !== "schemaVersion" && key !== "profiles")) return "invalid";
|
|
779
|
+
const profiles = {};
|
|
780
|
+
for (const [profile, overrides] of Object.entries(value.profiles)) {
|
|
781
|
+
if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile) || !record(overrides)) return "invalid";
|
|
782
|
+
const bindings = {};
|
|
783
|
+
for (const [id, binding] of Object.entries(overrides)) {
|
|
784
|
+
if (!commandPattern.test(id)) return "invalid";
|
|
785
|
+
const parsed = parseBinding(binding);
|
|
786
|
+
if (value.schemaVersion === 1 && parsed?.secondCode !== void 0) return "invalid";
|
|
787
|
+
bindings[id] = parsed;
|
|
788
|
+
}
|
|
789
|
+
profiles[profile] = bindings;
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
schemaVersion: value.schemaVersion,
|
|
793
|
+
profiles
|
|
794
|
+
};
|
|
795
|
+
} catch (_error) {
|
|
796
|
+
return "invalid";
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Check system, editor, and browser reservations using expanded physical modifiers.
|
|
801
|
+
* @param binding - normalized candidate.
|
|
802
|
+
* @param runtime - receiving application shell.
|
|
803
|
+
* @param platform - receiving device.
|
|
804
|
+
* @returns the rejection reason, or null when this combination is allowed.
|
|
805
|
+
*/
|
|
806
|
+
function bindingIssue(binding, runtime, platform) {
|
|
807
|
+
const { code, modifiers } = binding;
|
|
808
|
+
if (runtime === "desktop" && (platform === "windows" || platform === "macos")) return null;
|
|
809
|
+
if (binding.secondCode !== void 0) return "unsupported-key";
|
|
810
|
+
if (modifiers.length === 0 || modifiers.every((modifier) => modifier === "shift")) return "modifier-required";
|
|
811
|
+
if ((platform === "windows" || platform === "macos") && modifiers.length >= 3) return null;
|
|
812
|
+
if (runtime === "web" && (platform === "windows" || platform === "macos") && isWebBindingAllowed(binding, platform)) return null;
|
|
813
|
+
const primary = modifiers.includes(platform === "macos" ? "meta" : "control");
|
|
814
|
+
if ([
|
|
815
|
+
"Escape",
|
|
816
|
+
"Tab",
|
|
817
|
+
"Space",
|
|
818
|
+
"Backspace",
|
|
819
|
+
"Delete",
|
|
820
|
+
"ArrowUp",
|
|
821
|
+
"ArrowDown",
|
|
822
|
+
"ArrowLeft",
|
|
823
|
+
"ArrowRight"
|
|
824
|
+
].includes(code) || code === "Enter" && !modifiers.includes("alt") || primary && [
|
|
825
|
+
"KeyC",
|
|
826
|
+
"KeyV",
|
|
827
|
+
"KeyX",
|
|
828
|
+
"KeyZ",
|
|
829
|
+
"KeyY",
|
|
830
|
+
"KeyQ",
|
|
831
|
+
"KeyH"
|
|
832
|
+
].includes(code) || primary && code === "KeyA" && !modifiers.includes("shift") || platform !== "macos" && (modifiers.includes("meta") || modifiers.includes("alt") && ["F4", "F2"].includes(code)) || platform === "macos" && modifiers.includes("control") && modifiers.includes("meta") || platform === "macos" && modifiers.includes("alt") && !primary) return "reserved";
|
|
833
|
+
if (runtime === "web" && !isWebBindingAllowed(binding, platform)) return "unsupported-browser";
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Select the command owner's explicit default for one device profile.
|
|
838
|
+
* @param definition - command identity and per-profile defaults.
|
|
839
|
+
* @param runtime - receiving shell.
|
|
840
|
+
* @param platform - receiving device.
|
|
841
|
+
* @returns the declared physical binding, or undefined for an unbound action.
|
|
842
|
+
*/
|
|
843
|
+
function resolveShortcutDefault(definition, runtime, platform) {
|
|
844
|
+
return definition.defaults[`${runtime}:${platform}`];
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Resolve overrides and conflicts independently of registration order. Explicit overrides displace defaults.
|
|
848
|
+
* @param definitions - active commands.
|
|
849
|
+
* @param document - accepted preferences.
|
|
850
|
+
* @param runtime - receiving shell.
|
|
851
|
+
* @param platform - receiving device.
|
|
852
|
+
* @returns every active command, including unavailable conflicting bindings.
|
|
853
|
+
*/
|
|
854
|
+
function effectiveShortcuts(definitions, document, runtime, platform) {
|
|
855
|
+
const overrides = document.profiles[`${runtime}:${platform}`] ?? {};
|
|
856
|
+
const fixed = definitions.flatMap((row) => row.fixed?.map((binding) => ({
|
|
857
|
+
id: row.id,
|
|
858
|
+
binding: normalizeBinding(binding, platform)
|
|
859
|
+
})) ?? []);
|
|
860
|
+
const rows = definitions.filter((row) => row.fixed === void 0).map(({ id, defaults }) => {
|
|
861
|
+
const modified = Object.hasOwn(overrides, id);
|
|
862
|
+
const candidate = modified ? overrides[id] : resolveShortcutDefault({
|
|
863
|
+
id,
|
|
864
|
+
defaults
|
|
865
|
+
}, runtime, platform);
|
|
866
|
+
const binding = candidate == null ? null : normalizeBinding(candidate, platform);
|
|
867
|
+
return {
|
|
868
|
+
id,
|
|
869
|
+
binding,
|
|
870
|
+
modified,
|
|
871
|
+
issue: binding === null ? null : bindingIssue(binding, runtime, platform)
|
|
872
|
+
};
|
|
873
|
+
});
|
|
874
|
+
return rows.map((row) => {
|
|
875
|
+
const binding = row.binding;
|
|
876
|
+
return {
|
|
877
|
+
...row,
|
|
878
|
+
conflicts: binding === null ? [] : [.../* @__PURE__ */ new Set([...rows.filter((other) => other.id !== row.id && other.binding !== null && other.issue === null && overlappingBindings(other.binding, binding) && (!row.modified || other.modified)).map((other) => other.id), ...fixed.filter((other) => overlappingBindings(other.binding, binding)).map((other) => other.id)])]
|
|
879
|
+
};
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Detect identical combinations or a single key contained in a two-key chord.
|
|
884
|
+
* @param left - normalized candidate.
|
|
885
|
+
* @param right - normalized occupied binding.
|
|
886
|
+
* @returns whether both bindings require the same modifiers and overlap.
|
|
887
|
+
*/
|
|
888
|
+
function overlappingBindings(left, right) {
|
|
889
|
+
if (left.modifiers.join("+") !== right.modifiers.join("+")) return false;
|
|
890
|
+
if (left.secondCode !== void 0 && right.secondCode !== void 0) return bindingKey(left) === bindingKey(right);
|
|
891
|
+
return [left.code, left.secondCode].some((code) => code !== void 0 && (code === right.code || code === right.secondCode));
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Apply an edit without modifying other profiles or dormant overrides.
|
|
895
|
+
* @param document - accepted document.
|
|
896
|
+
* @param edit - validated operation.
|
|
897
|
+
* @param runtime - current shell.
|
|
898
|
+
* @param platform - current device.
|
|
899
|
+
* @returns the candidate document, pending conflict checks and durable storage.
|
|
900
|
+
*/
|
|
901
|
+
function editShortcutDocument(document, edit, runtime, platform) {
|
|
902
|
+
const schemaVersion = runtime === "desktop" && (platform === "macos" || platform === "windows") ? 2 : document.schemaVersion;
|
|
903
|
+
const profile = `${runtime}:${platform}`;
|
|
904
|
+
let overrides = { ...document.profiles[profile] };
|
|
905
|
+
switch (edit.type) {
|
|
906
|
+
case "set":
|
|
907
|
+
overrides[edit.id] = edit.binding;
|
|
908
|
+
break;
|
|
909
|
+
case "reset": {
|
|
910
|
+
const { [edit.id]: _removed, ...remaining } = overrides;
|
|
911
|
+
overrides = remaining;
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
case "reset-all":
|
|
915
|
+
overrides = {};
|
|
916
|
+
break;
|
|
917
|
+
/* v8 ignore next -- parseShortcutEdit validates this closed union before persistence. */
|
|
918
|
+
default: return assertNever(edit, "shortcut edit");
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
schemaVersion,
|
|
922
|
+
profiles: {
|
|
923
|
+
...document.profiles,
|
|
924
|
+
[profile]: overrides
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Validate a preference edit at the Desktop IPC boundary.
|
|
930
|
+
* @param value - untrusted renderer request.
|
|
931
|
+
* @returns the constrained operation; malformed requests throw.
|
|
932
|
+
*/
|
|
933
|
+
function parseShortcutEdit(value) {
|
|
934
|
+
if (!record(value)) throw new Error("Invalid shortcut edit");
|
|
935
|
+
if (value.type === "reset-all" && Object.keys(value).length === 1) return { type: value.type };
|
|
936
|
+
if (typeof value.id !== "string" || !commandPattern.test(value.id)) throw new Error("Invalid shortcut command");
|
|
937
|
+
if (value.type === "reset" && Object.keys(value).length === 2) return {
|
|
938
|
+
type: "reset",
|
|
939
|
+
id: value.id
|
|
940
|
+
};
|
|
941
|
+
if (value.type === "set" && Object.keys(value).length === 3) return {
|
|
942
|
+
type: "set",
|
|
943
|
+
id: value.id,
|
|
944
|
+
binding: parseBinding(value.binding)
|
|
945
|
+
};
|
|
946
|
+
throw new Error("Invalid shortcut edit");
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Validate the trusted product's serializable command catalog at IPC ingress.
|
|
950
|
+
* @param value - renderer-supplied active command definitions.
|
|
951
|
+
* @returns validated definitions; duplicate IDs, overlapping defaults, and unsupported combinations throw.
|
|
952
|
+
*/
|
|
953
|
+
function parseShortcutDefinitions(value) {
|
|
954
|
+
if (!Array.isArray(value)) throw new Error("Invalid shortcut catalog");
|
|
955
|
+
const ids = /* @__PURE__ */ new Set();
|
|
956
|
+
for (const entry of value) {
|
|
957
|
+
if (!record(entry) || typeof entry.id !== "string" || !commandPattern.test(entry.id) || ids.has(entry.id) || !record(entry.defaults) || Object.keys(entry).some((key) => key !== "id" && key !== "defaults" && key !== "fixed")) throw new Error("Invalid shortcut definition");
|
|
958
|
+
ids.add(entry.id);
|
|
959
|
+
if (Object.hasOwn(entry, "fixed")) {
|
|
960
|
+
if (!Array.isArray(entry.fixed) || entry.fixed.length === 0 || Object.keys(entry.defaults).length > 0) throw new Error("Invalid fixed shortcut definition");
|
|
961
|
+
for (const binding of entry.fixed) if (parseBinding(binding) === null) throw new Error("Invalid fixed shortcut binding");
|
|
962
|
+
}
|
|
963
|
+
for (const [profile, candidate] of Object.entries(entry.defaults)) {
|
|
964
|
+
if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile)) throw new Error("Invalid shortcut profile");
|
|
965
|
+
if (parseBinding(candidate) === null) throw new Error("Invalid shortcut default");
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const definitions = value;
|
|
969
|
+
for (const runtime of ["desktop", "web"]) for (const platform of [
|
|
970
|
+
"macos",
|
|
971
|
+
"windows",
|
|
972
|
+
"linux"
|
|
973
|
+
]) {
|
|
974
|
+
const bindings = [];
|
|
975
|
+
for (const entry of definitions) {
|
|
976
|
+
const binding = resolveShortcutDefault(entry, runtime, platform);
|
|
977
|
+
if (binding === void 0) continue;
|
|
978
|
+
const normalized = normalizeBinding(binding, platform);
|
|
979
|
+
if (bindings.some((other) => overlappingBindings(other, normalized)) || bindingIssue(normalized, runtime, platform) !== null) throw new Error("Conflicting or reserved shortcut default");
|
|
980
|
+
bindings.push(normalized);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return definitions;
|
|
984
|
+
}
|
|
985
|
+
/** Serialized preference transactions; storage owners publish only accepted writes or read diagnostics. */
|
|
986
|
+
/**
|
|
987
|
+
* Create a disabled initial snapshot for asynchronous adapter startup.
|
|
988
|
+
* @returns a fresh configuration with no accepted persisted state.
|
|
989
|
+
*/
|
|
990
|
+
function initialShortcutConfig() {
|
|
991
|
+
return {
|
|
992
|
+
revision: randomUUID(),
|
|
993
|
+
sequence: 0,
|
|
994
|
+
document: {
|
|
995
|
+
schemaVersion: 1,
|
|
996
|
+
profiles: {}
|
|
997
|
+
},
|
|
998
|
+
status: "loading",
|
|
999
|
+
error: null,
|
|
1000
|
+
usingDefaults: true
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
/** Single-writer coordinator shared by localStorage and Electron's atomic file adapter. */
|
|
1004
|
+
var ShortcutPersistence = class {
|
|
1005
|
+
storage;
|
|
1006
|
+
runtime;
|
|
1007
|
+
platform;
|
|
1008
|
+
rereadBeforeWrite;
|
|
1009
|
+
publish;
|
|
1010
|
+
snapshot = initialShortcutConfig();
|
|
1011
|
+
raw;
|
|
1012
|
+
queue = Promise.resolve();
|
|
1013
|
+
definitions = null;
|
|
1014
|
+
active = true;
|
|
1015
|
+
constructor(storage, runtime, platform, rereadBeforeWrite, publish) {
|
|
1016
|
+
this.storage = storage;
|
|
1017
|
+
this.runtime = runtime;
|
|
1018
|
+
this.platform = platform;
|
|
1019
|
+
this.rereadBeforeWrite = rereadBeforeWrite;
|
|
1020
|
+
this.publish = publish;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Install or revoke a product catalog and invalidate drafts from its previous lifetime.
|
|
1024
|
+
* @param definitions - current trusted definitions, or null while the product is not ready.
|
|
1025
|
+
*/
|
|
1026
|
+
setDefinitions(definitions) {
|
|
1027
|
+
this.definitions = definitions;
|
|
1028
|
+
this.accept({ ...this.snapshot });
|
|
1029
|
+
}
|
|
1030
|
+
/** Stop accepting edits or publishing late completions. */
|
|
1031
|
+
dispose() {
|
|
1032
|
+
this.active = false;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Read the current file; failures retain the last accepted document and disable ordinary writes.
|
|
1036
|
+
* @returns the accepted snapshot or diagnostic snapshot.
|
|
1037
|
+
*/
|
|
1038
|
+
readCurrent() {
|
|
1039
|
+
return this.serialize(() => this.read());
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Compare the draft revision, validate the complete candidate, then persist before publishing.
|
|
1043
|
+
* @param edit - constrained preference operation.
|
|
1044
|
+
* @param revision - state against which the user reviewed the edit.
|
|
1045
|
+
* @returns a classified outcome and the currently accepted snapshot.
|
|
1046
|
+
*/
|
|
1047
|
+
edit(edit, revision) {
|
|
1048
|
+
return this.serialize(async () => {
|
|
1049
|
+
if (this.rereadBeforeWrite) await this.read();
|
|
1050
|
+
const result = (status) => ({
|
|
1051
|
+
status,
|
|
1052
|
+
snapshot: this.snapshot
|
|
1053
|
+
});
|
|
1054
|
+
if (!this.active || this.definitions === null || this.snapshot.status === "loading") return result("not-ready");
|
|
1055
|
+
if (revision !== this.snapshot.revision) return result("stale");
|
|
1056
|
+
if (this.snapshot.status === "unreadable") return result("unreadable");
|
|
1057
|
+
if ((edit.type === "set" || edit.type === "reset") && !this.definitions.some((row) => row.id === edit.id && row.fixed === void 0)) return result("not-ready");
|
|
1058
|
+
const document = editShortcutDocument(this.snapshot.document, edit, this.runtime, this.platform);
|
|
1059
|
+
const rows = effectiveShortcuts(this.definitions, document, this.runtime, this.platform);
|
|
1060
|
+
const invalid = rows.find((row) => (edit.type === "reset-all" || row.id === edit.id) && (row.issue !== null || row.conflicts.length > 0));
|
|
1061
|
+
const displaced = edit.type === "set" ? rows.find((row) => row.conflicts.includes(edit.id)) : void 0;
|
|
1062
|
+
if (invalid !== void 0 || displaced !== void 0) return {
|
|
1063
|
+
...result("conflict"),
|
|
1064
|
+
...invalid?.issue ? { issue: invalid.issue } : {},
|
|
1065
|
+
conflicts: invalid?.conflicts.length ? invalid.conflicts : displaced === void 0 ? [] : [displaced.id]
|
|
1066
|
+
};
|
|
1067
|
+
try {
|
|
1068
|
+
const raw = `${JSON.stringify(document, null, 2)}\n`;
|
|
1069
|
+
await this.storage.write(raw);
|
|
1070
|
+
this.raw = raw;
|
|
1071
|
+
this.accept({
|
|
1072
|
+
...this.snapshot,
|
|
1073
|
+
document,
|
|
1074
|
+
status: "ready",
|
|
1075
|
+
error: null,
|
|
1076
|
+
usingDefaults: false
|
|
1077
|
+
});
|
|
1078
|
+
return result("saved");
|
|
1079
|
+
} catch (_error) {
|
|
1080
|
+
return result("write-failed");
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
serialize(operation) {
|
|
1085
|
+
const next = this.queue.then(operation);
|
|
1086
|
+
this.queue = next.catch(() => void 0);
|
|
1087
|
+
return next;
|
|
1088
|
+
}
|
|
1089
|
+
accept(snapshot) {
|
|
1090
|
+
this.snapshot = {
|
|
1091
|
+
...snapshot,
|
|
1092
|
+
sequence: this.snapshot.sequence + 1,
|
|
1093
|
+
revision: randomUUID()
|
|
1094
|
+
};
|
|
1095
|
+
if (!this.active) return;
|
|
1096
|
+
try {
|
|
1097
|
+
this.publish(this.snapshot);
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
console.error("Shortcut configuration subscriber failed:", error);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async read() {
|
|
1103
|
+
let raw;
|
|
1104
|
+
try {
|
|
1105
|
+
raw = await this.storage.read();
|
|
1106
|
+
} catch (_error) {
|
|
1107
|
+
if (this.snapshot.error !== "read") this.accept({
|
|
1108
|
+
...this.snapshot,
|
|
1109
|
+
status: "unreadable",
|
|
1110
|
+
error: "read"
|
|
1111
|
+
});
|
|
1112
|
+
return this.snapshot;
|
|
1113
|
+
}
|
|
1114
|
+
if (raw === this.raw && this.snapshot.error !== "read") return this.snapshot;
|
|
1115
|
+
this.raw = raw;
|
|
1116
|
+
const document = parseShortcutDocument(raw);
|
|
1117
|
+
if (typeof document === "string") this.accept({
|
|
1118
|
+
...this.snapshot,
|
|
1119
|
+
status: "unreadable",
|
|
1120
|
+
error: document
|
|
1121
|
+
});
|
|
1122
|
+
else this.accept({
|
|
1123
|
+
...this.snapshot,
|
|
1124
|
+
document,
|
|
1125
|
+
status: "ready",
|
|
1126
|
+
error: null,
|
|
1127
|
+
usingDefaults: raw === null
|
|
1128
|
+
});
|
|
1129
|
+
return this.snapshot;
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
//#endregion
|
|
1133
|
+
//#region ../../../vendor/deepseek-harness/packages/util/atomic-write/lib/index.js
|
|
1134
|
+
/**
|
|
1135
|
+
* Zero-dependency atomic file replacement and writer coordination.
|
|
1136
|
+
* `writeFileAtomic` writes a random-suffix sibling with exclusive create and
|
|
1137
|
+
* the caller's permission bits, then renames it over the target, so readers
|
|
1138
|
+
* observe either the old or the new complete content and a replaced file ends
|
|
1139
|
+
* up with exactly the stated mode. `withFileLock` serializes cross-process
|
|
1140
|
+
* writers of one file through a `wx`-created `<file>.lock` sibling, so a
|
|
1141
|
+
* read-modify-write cycle can never resurrect a state another writer just
|
|
1142
|
+
* replaced; readers stay lock-free because the rename commit is atomic. A lock
|
|
1143
|
+
* whose recorded holder process no longer exists is taken over.
|
|
1144
|
+
* @module @deepseek-ai/dsh-atomic-write
|
|
1145
|
+
*/
|
|
1146
|
+
const WINDOWS_TRANSIENT_RENAME_ERRORS = /* @__PURE__ */ new Set([
|
|
1147
|
+
"EACCES",
|
|
1148
|
+
"EBUSY",
|
|
1149
|
+
"EPERM"
|
|
1150
|
+
]);
|
|
1151
|
+
const WINDOWS_RENAME_RETRY_INITIAL_MS = 20;
|
|
1152
|
+
const WINDOWS_RENAME_RETRY_MAX_MS = 200;
|
|
1153
|
+
const WINDOWS_RENAME_RETRY_LIMIT = 8;
|
|
1154
|
+
/** Whether Windows reported temporary interference with an atomic replacement. */
|
|
1155
|
+
function isTransientWindowsRenameError(error) {
|
|
1156
|
+
if (process.platform !== "win32") return false;
|
|
1157
|
+
return WINDOWS_TRANSIENT_RENAME_ERRORS.has(error?.code ?? "");
|
|
1158
|
+
}
|
|
1159
|
+
/** Replace the target after bounded retries for transient Windows interference. */
|
|
1160
|
+
async function renameAtomicTemp(temp, filename) {
|
|
1161
|
+
let delay = WINDOWS_RENAME_RETRY_INITIAL_MS;
|
|
1162
|
+
for (let retries = 0;; retries += 1) {
|
|
1163
|
+
try {
|
|
1164
|
+
await rename(temp, filename);
|
|
1165
|
+
return;
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
if (!isTransientWindowsRenameError(error)) throw error;
|
|
1168
|
+
if (retries >= WINDOWS_RENAME_RETRY_LIMIT) throw error;
|
|
1169
|
+
}
|
|
1170
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1171
|
+
delay = Math.min(delay * 2, WINDOWS_RENAME_RETRY_MAX_MS);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Replace `filename` with `content` in one atomic step, creating parent
|
|
1176
|
+
* directories. The content is first written to a random-suffix sibling opened
|
|
1177
|
+
* with exclusive create (`wx`): the open refuses to follow a symlink planted
|
|
1178
|
+
* at the temp path, and the fresh inode carries `options.mode` through the
|
|
1179
|
+
* rename, so replacing a wider-permission file narrows it without a chmod
|
|
1180
|
+
* race. The rename also replaces a symlinked target itself instead of writing
|
|
1181
|
+
* through to its referent, and the same-directory sibling keeps the rename on
|
|
1182
|
+
* one filesystem. Windows replacement retries transient `EACCES`, `EBUSY`,
|
|
1183
|
+
* and `EPERM` failures for a bounded interval while the complete temp file
|
|
1184
|
+
* remains the rename source. On any remaining failure the temp file is
|
|
1185
|
+
* removed and the failure rethrown. Crash durability (fsync) is out of scope.
|
|
1186
|
+
* @param filename - final path receiving the content.
|
|
1187
|
+
* @param content - complete next file content.
|
|
1188
|
+
* @param options - permission bits for the replacement inode.
|
|
1189
|
+
*/
|
|
1190
|
+
async function writeFileAtomic(filename, content, options) {
|
|
1191
|
+
await mkdir(dirname(filename), {
|
|
1192
|
+
recursive: true,
|
|
1193
|
+
...options.dirMode === void 0 ? {} : { mode: options.dirMode }
|
|
1194
|
+
});
|
|
1195
|
+
const temp = `${filename}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1196
|
+
try {
|
|
1197
|
+
await writeFile(temp, content, {
|
|
1198
|
+
mode: options.mode,
|
|
1199
|
+
flag: "wx"
|
|
1200
|
+
});
|
|
1201
|
+
await renameAtomicTemp(temp, filename);
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
await rm(temp, { force: true });
|
|
1204
|
+
throw error;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
//#endregion
|
|
1208
|
+
//#region src/keybindings.ts
|
|
1209
|
+
/** 设备本地的快捷键偏好(Electron userData 下):原子替换、单写者协调。 */
|
|
1210
|
+
/**
|
|
1211
|
+
* 打开设备配置(原子替换)。
|
|
1212
|
+
* @param userData - Electron 的 userData 目录,不接受渲染进程给的路径。
|
|
1213
|
+
* @param platform - 本机输入平台。
|
|
1214
|
+
* @param publish - 提交后更新原生绑定索引与受信页面。
|
|
1215
|
+
* @returns 单写者事务协调器。
|
|
1216
|
+
*/
|
|
1217
|
+
function desktopKeybindings(userData, platform, publish) {
|
|
1218
|
+
const path = join(userData, "keybindings.json");
|
|
1219
|
+
return new ShortcutPersistence({
|
|
1220
|
+
read: async () => {
|
|
1221
|
+
try {
|
|
1222
|
+
return await readFile(path, "utf8");
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
if (error.code === "ENOENT") return null;
|
|
1225
|
+
throw error;
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
write: (raw) => writeFileAtomic(path, raw, {
|
|
1229
|
+
mode: 384,
|
|
1230
|
+
dirMode: 448
|
|
1231
|
+
})
|
|
1232
|
+
}, "desktop", platform, false, publish);
|
|
1233
|
+
}
|
|
1234
|
+
//#endregion
|
|
1235
|
+
//#region src/shortcuts.ts
|
|
1236
|
+
/**
|
|
1237
|
+
* 桌面快捷键的原生侧:设备偏好持久化 + 原生按键转发 + 关闭窗口动作。
|
|
1238
|
+
*
|
|
1239
|
+
* 上游 `@deepseek-ai/dsh-client-shortcuts` 在 `data-platform` 存在时按 desktop 运行时起服务,
|
|
1240
|
+
* 并**强制要求** `window.dshDesktop.keyboard`(缺了就构造失败)——这里就是那份契约的实现:
|
|
1241
|
+
* 主进程按已绑定键过滤物理按键,把命中的手势经 IPC 交给渲染进程派发。
|
|
1242
|
+
*/
|
|
1243
|
+
/**
|
|
1244
|
+
* 装上桌面快捷键的原生侧。
|
|
1245
|
+
* @param getWindow - 当前主窗口。
|
|
1246
|
+
* @param userData - Electron 的 userData 目录。
|
|
1247
|
+
* @param platform - 本机输入平台。
|
|
1248
|
+
* @param scheme - 产品页面的自定义协议(校验 IPC 来源)。
|
|
1249
|
+
* @returns 窗口接入与释放操作。
|
|
1250
|
+
*/
|
|
1251
|
+
function installDesktopShortcuts(getWindow, userData, platform, scheme) {
|
|
1252
|
+
let definitions = [];
|
|
1253
|
+
let recording = false;
|
|
1254
|
+
let revision;
|
|
1255
|
+
let keys = /* @__PURE__ */ new Set();
|
|
1256
|
+
const publish = (snapshot) => {
|
|
1257
|
+
revision = definitions.length === 0 || snapshot.status === "loading" ? void 0 : snapshot.revision;
|
|
1258
|
+
const rows = snapshot.status === "loading" ? [] : effectiveShortcuts(definitions, snapshot.document, "desktop", platform);
|
|
1259
|
+
keys = new Set(rows.flatMap((row) => row.binding !== null && row.issue === null && row.conflicts.length === 0 ? [bindingKey(row.binding)] : []));
|
|
1260
|
+
const window = getWindow();
|
|
1261
|
+
if (window !== void 0 && !window.isDestroyed()) window.webContents.send(DESKTOP_IPC.shortcutsChanged, snapshot);
|
|
1262
|
+
};
|
|
1263
|
+
const persistence = desktopKeybindings(userData, platform, publish);
|
|
1264
|
+
const windowOf = (event) => {
|
|
1265
|
+
assertDesktopSender(event, scheme, ["app"]);
|
|
1266
|
+
const window = getWindow();
|
|
1267
|
+
if (window === void 0 || window.isDestroyed() || event.sender !== window.webContents) throw new Error("desktop shortcuts: rejected sender");
|
|
1268
|
+
return window;
|
|
1269
|
+
};
|
|
1270
|
+
ipcMain.handle(DESKTOP_IPC.shortcutsGet, (event, input) => {
|
|
1271
|
+
windowOf(event);
|
|
1272
|
+
definitions = parseShortcutDefinitions(input);
|
|
1273
|
+
persistence.setDefinitions(definitions);
|
|
1274
|
+
return persistence.readCurrent();
|
|
1275
|
+
});
|
|
1276
|
+
ipcMain.handle(DESKTOP_IPC.shortcutsEdit, (event, input, expected) => {
|
|
1277
|
+
windowOf(event);
|
|
1278
|
+
if (typeof expected !== "string") throw new Error("desktop shortcuts: invalid revision");
|
|
1279
|
+
return persistence.edit(parseShortcutEdit(input), expected);
|
|
1280
|
+
});
|
|
1281
|
+
ipcMain.handle(DESKTOP_IPC.shortcutsRecording, (event, active) => {
|
|
1282
|
+
const window = windowOf(event);
|
|
1283
|
+
if (typeof active !== "boolean") throw new Error("desktop shortcuts: invalid recording state");
|
|
1284
|
+
recording = active;
|
|
1285
|
+
window.webContents.setIgnoreMenuShortcuts(active);
|
|
1286
|
+
});
|
|
1287
|
+
ipcMain.handle(DESKTOP_IPC.shortcutsCloseWindow, (event, expected) => {
|
|
1288
|
+
const window = windowOf(event);
|
|
1289
|
+
if (expected !== revision || revision === void 0 || recording) return;
|
|
1290
|
+
if (!window.isFocused() || !window.isEnabled()) return;
|
|
1291
|
+
window.close();
|
|
1292
|
+
});
|
|
1293
|
+
const attach = (window) => {
|
|
1294
|
+
const contents = window.webContents;
|
|
1295
|
+
let deadKey = false;
|
|
1296
|
+
const held = /* @__PURE__ */ new Set();
|
|
1297
|
+
const consumed = /* @__PURE__ */ new Map();
|
|
1298
|
+
const resetInput = () => {
|
|
1299
|
+
deadKey = false;
|
|
1300
|
+
held.clear();
|
|
1301
|
+
consumed.clear();
|
|
1302
|
+
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false);
|
|
1303
|
+
};
|
|
1304
|
+
const clear = () => {
|
|
1305
|
+
resetInput();
|
|
1306
|
+
definitions = [];
|
|
1307
|
+
keys.clear();
|
|
1308
|
+
recording = false;
|
|
1309
|
+
persistence.setDefinitions(null);
|
|
1310
|
+
};
|
|
1311
|
+
const beforeInput = (event, input) => {
|
|
1312
|
+
if (event.defaultPrevented) {
|
|
1313
|
+
resetInput();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
if (window !== getWindow() || !window.isFocused() || !window.isEnabled() || revision === void 0) {
|
|
1317
|
+
contents.setIgnoreMenuShortcuts(false);
|
|
1318
|
+
held.clear();
|
|
1319
|
+
consumed.clear();
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const modifiers = [
|
|
1323
|
+
"control",
|
|
1324
|
+
"alt",
|
|
1325
|
+
"shift",
|
|
1326
|
+
"meta"
|
|
1327
|
+
].filter((modifier) => input[modifier]);
|
|
1328
|
+
const key = bindingKey({
|
|
1329
|
+
code: input.code,
|
|
1330
|
+
modifiers
|
|
1331
|
+
});
|
|
1332
|
+
const match = keys.has(key);
|
|
1333
|
+
contents.setIgnoreMenuShortcuts(recording || match);
|
|
1334
|
+
const composing = input.isComposing || input.key === "Dead" || deadKey || input.modifiers.includes("altgr");
|
|
1335
|
+
if (input.type === "keyDown") deadKey = input.key === "Dead";
|
|
1336
|
+
if (recording || composing) {
|
|
1337
|
+
held.clear();
|
|
1338
|
+
consumed.clear();
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
let binding = {
|
|
1342
|
+
code: input.code,
|
|
1343
|
+
modifiers
|
|
1344
|
+
};
|
|
1345
|
+
let priority = false;
|
|
1346
|
+
const modifierKey = /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(input.code);
|
|
1347
|
+
if (input.type === "keyUp") {
|
|
1348
|
+
if (consumed.get(input.code) === "press") event.preventDefault();
|
|
1349
|
+
consumed.delete(input.code);
|
|
1350
|
+
held.delete(input.code);
|
|
1351
|
+
if (modifierKey) held.clear();
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (!input.isAutoRepeat) consumed.delete(input.code);
|
|
1355
|
+
if (modifierKey) {
|
|
1356
|
+
held.clear();
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (input.isAutoRepeat && consumed.has(input.code) && !match) {
|
|
1360
|
+
event.preventDefault();
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
if (input.isAutoRepeat && !held.has(input.code) && !match) return;
|
|
1364
|
+
held.add(input.code);
|
|
1365
|
+
const codes = [input.code, ...[...held].filter((value) => value !== input.code)];
|
|
1366
|
+
codes.sort();
|
|
1367
|
+
const pair = {
|
|
1368
|
+
code: codes[0],
|
|
1369
|
+
...codes[1] === void 0 ? {} : { secondCode: codes[1] },
|
|
1370
|
+
modifiers
|
|
1371
|
+
};
|
|
1372
|
+
priority = keys.has(key);
|
|
1373
|
+
if (codes.length === 2 && keys.has(bindingKey(pair))) {
|
|
1374
|
+
binding = pair;
|
|
1375
|
+
priority = true;
|
|
1376
|
+
}
|
|
1377
|
+
if (!priority) return;
|
|
1378
|
+
event.preventDefault();
|
|
1379
|
+
if (!input.isAutoRepeat) {
|
|
1380
|
+
if (binding.secondCode !== void 0) {
|
|
1381
|
+
consumed.set(binding.code, "repeat");
|
|
1382
|
+
consumed.set(binding.secondCode, "repeat");
|
|
1383
|
+
}
|
|
1384
|
+
consumed.set(input.code, "press");
|
|
1385
|
+
}
|
|
1386
|
+
held.clear();
|
|
1387
|
+
contents.send(DESKTOP_IPC.shortcutsInput, {
|
|
1388
|
+
kind: "keyboard",
|
|
1389
|
+
revision,
|
|
1390
|
+
code: binding.code,
|
|
1391
|
+
...binding.secondCode === void 0 ? {} : { secondCode: binding.secondCode },
|
|
1392
|
+
repeat: input.isAutoRepeat,
|
|
1393
|
+
control: input.control,
|
|
1394
|
+
alt: input.alt,
|
|
1395
|
+
shift: input.shift,
|
|
1396
|
+
meta: input.meta
|
|
1397
|
+
});
|
|
1398
|
+
};
|
|
1399
|
+
const navigation = (event) => {
|
|
1400
|
+
if (event.isMainFrame && !event.isSameDocument) clear();
|
|
1401
|
+
};
|
|
1402
|
+
const disposeInput = () => {
|
|
1403
|
+
contents.off("did-start-navigation", navigation);
|
|
1404
|
+
contents.off("before-input-event", beforeInput);
|
|
1405
|
+
contents.off("blur", resetInput);
|
|
1406
|
+
resetInput();
|
|
1407
|
+
};
|
|
1408
|
+
contents.on("did-start-navigation", navigation);
|
|
1409
|
+
contents.on("before-input-event", beforeInput);
|
|
1410
|
+
contents.on("blur", resetInput);
|
|
1411
|
+
contents.once("destroyed", disposeInput);
|
|
1412
|
+
window.once("closed", clear);
|
|
1413
|
+
};
|
|
1414
|
+
return {
|
|
1415
|
+
attach,
|
|
1416
|
+
dispose() {
|
|
1417
|
+
for (const channel of [
|
|
1418
|
+
DESKTOP_IPC.shortcutsGet,
|
|
1419
|
+
DESKTOP_IPC.shortcutsEdit,
|
|
1420
|
+
DESKTOP_IPC.shortcutsRecording,
|
|
1421
|
+
DESKTOP_IPC.shortcutsCloseWindow
|
|
1422
|
+
]) ipcMain.removeHandler(channel);
|
|
1423
|
+
persistence.dispose();
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
//#endregion
|
|
1428
|
+
//#region src/shell-env.ts
|
|
1429
|
+
function rcFileFor(shell) {
|
|
1430
|
+
switch (basename(shell)) {
|
|
1431
|
+
case "bash": return "~/.bashrc";
|
|
1432
|
+
case "zsh": return "~/.zshrc";
|
|
1433
|
+
default: return "";
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
function shellQuote(value) {
|
|
1437
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
1438
|
+
}
|
|
1439
|
+
function shellWrappedSpawn(command, args, options) {
|
|
1440
|
+
const shell = process.env.SHELL;
|
|
1441
|
+
const rc = shell === void 0 || shell === "" ? "" : rcFileFor(shell);
|
|
1442
|
+
if (rc !== "" && process.platform !== "win32" && shell !== void 0) {
|
|
1443
|
+
const commandLine = `source ${rc} >/dev/null 2>&1; exec ${shellQuote(command)} ${args.map(shellQuote).join(" ")}`;
|
|
1444
|
+
return spawn(shell, ["-c", commandLine], {
|
|
1445
|
+
...options,
|
|
1446
|
+
detached: true
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
return spawn(command, [...args], {
|
|
1450
|
+
...options,
|
|
1451
|
+
detached: process.platform !== "win32"
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
//#endregion
|
|
1455
|
+
//#region src/index.ts
|
|
1456
|
+
let focusPrimaryWindow = () => {};
|
|
1457
|
+
/** appconfig.json 的位置:打包形态只看 resources,dev 形态由 CLI 指到构建目录。 */
|
|
1458
|
+
function appConfigDir() {
|
|
1459
|
+
const configured = process.env.DSH_DESKTOP_APPCONFIG_DIR;
|
|
1460
|
+
return app.isPackaged || configured === void 0 || configured === "" ? process.resourcesPath : configured;
|
|
1461
|
+
}
|
|
1462
|
+
function startupFailure(error) {
|
|
1463
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1464
|
+
console.error(error);
|
|
1465
|
+
dialog.showErrorBox("dsh desktop startup failed", message);
|
|
1466
|
+
app.exit(1);
|
|
1467
|
+
throw error instanceof Error ? error : new Error(message);
|
|
1468
|
+
}
|
|
1469
|
+
const appConfig = await loadAppConfig(appConfigDir()).catch((error) => startupFailure(error));
|
|
1470
|
+
/** 自定义协议取自 app 名:与官方桌面应用(`dsh-app`)错开。 */
|
|
1471
|
+
const SCHEME = (() => {
|
|
1472
|
+
try {
|
|
1473
|
+
return desktopScheme(appConfig.name);
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
startupFailure(error);
|
|
1476
|
+
}
|
|
1477
|
+
})();
|
|
1478
|
+
protocol.registerSchemesAsPrivileged([{
|
|
1479
|
+
scheme: SCHEME,
|
|
1480
|
+
privileges: {
|
|
1481
|
+
standard: true,
|
|
1482
|
+
secure: true,
|
|
1483
|
+
supportFetchAPI: true,
|
|
1484
|
+
corsEnabled: true,
|
|
1485
|
+
stream: true,
|
|
1486
|
+
codeCache: true
|
|
1487
|
+
}
|
|
1488
|
+
}]);
|
|
1489
|
+
const WEB_FRONTEND_PACKAGE = "@deepseek-ai/dsh-web-frontend";
|
|
1490
|
+
const APPLICATION_URL = `${SCHEME}://app/`;
|
|
1491
|
+
/** Windows 自绘标题栏高度(DIP),与 preload 写入的 CSS 变量一致。 */
|
|
1492
|
+
const WINDOWS_TITLEBAR_HEIGHT = 40;
|
|
1493
|
+
async function pathExists(path) {
|
|
1494
|
+
try {
|
|
1495
|
+
await access(path);
|
|
1496
|
+
return true;
|
|
1497
|
+
} catch {
|
|
1498
|
+
return false;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
function runtimeResources() {
|
|
1502
|
+
const development = !app.isPackaged;
|
|
1503
|
+
const node = (development ? process.env.DSH_DESKTOP_NODE_BINARY : void 0) ?? join(process.resourcesPath, "runtime", "node", process.platform === "win32" ? "node.exe" : "node");
|
|
1504
|
+
const seed = (development ? process.env.DSH_DESKTOP_SEED_DIR : void 0) ?? join(process.resourcesPath, "seed");
|
|
1505
|
+
const configured = process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR;
|
|
1506
|
+
return {
|
|
1507
|
+
node,
|
|
1508
|
+
seed,
|
|
1509
|
+
primaryRuntime: configured !== void 0 && configured !== "" ? resolve(configured) : join(process.resourcesPath, "runtime", "primary-runtime"),
|
|
1510
|
+
runtime: join(seed, SEED_RUNTIME_DIR_NAME),
|
|
1511
|
+
pnpm: join(process.resourcesPath, "runtime", "pnpm", "bin", "pnpm.mjs"),
|
|
1512
|
+
nodeBin: join(process.resourcesPath, "runtime", "bin")
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
async function installPlantedProfile(profileDir, resources) {
|
|
1516
|
+
const settingsPath = join(profileDir, PROFILE_WORKSPACE_NAME);
|
|
1517
|
+
const overrides = await runtimeOverrides(profileDir, resources.runtime);
|
|
1518
|
+
await writeFile(settingsPath, workspaceWithOverrides(await readFile(settingsPath, "utf8"), overrides));
|
|
1519
|
+
await installProfile({
|
|
1520
|
+
node: resources.node,
|
|
1521
|
+
pnpmEntry: resources.pnpm,
|
|
1522
|
+
profileDir
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
function developmentProject() {
|
|
1526
|
+
const configured = process.env.DSH_DESKTOP_DEV_PROJECT_DIR;
|
|
1527
|
+
if (configured === void 0 || configured === "") return void 0;
|
|
1528
|
+
if (app.isPackaged) throw new Error("dsh desktop: development project override is unavailable in packaged applications");
|
|
1529
|
+
return resolve(configured);
|
|
1530
|
+
}
|
|
1531
|
+
/** 窗口形态:macOS 走 sidebar vibrancy + hiddenInset(交通灯落在侧边栏内),Windows 自绘 caption。 */
|
|
1532
|
+
function windowFrame() {
|
|
1533
|
+
if (process.platform === "darwin") return {
|
|
1534
|
+
titleBarStyle: "hiddenInset",
|
|
1535
|
+
trafficLightPosition: {
|
|
1536
|
+
x: 16,
|
|
1537
|
+
y: 18
|
|
1538
|
+
},
|
|
1539
|
+
vibrancy: "sidebar",
|
|
1540
|
+
visualEffectState: "active",
|
|
1541
|
+
backgroundColor: "#00000000"
|
|
1542
|
+
};
|
|
1543
|
+
if (process.platform === "win32") return {
|
|
1544
|
+
titleBarStyle: "hidden",
|
|
1545
|
+
titleBarOverlay: {
|
|
1546
|
+
height: WINDOWS_TITLEBAR_HEIGHT,
|
|
1547
|
+
color: nativeTheme.shouldUseDarkColors ? "#1b1b1c" : "#f9fafb",
|
|
1548
|
+
symbolColor: nativeTheme.shouldUseDarkColors ? "#f9fafb" : "#0f1115"
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
return { frame: false };
|
|
1552
|
+
}
|
|
1553
|
+
function createWindow(preload, config) {
|
|
1554
|
+
const window = new BrowserWindow({
|
|
1555
|
+
width: config.width,
|
|
1556
|
+
height: config.height,
|
|
1557
|
+
minWidth: config.minWidth,
|
|
1558
|
+
minHeight: config.minHeight,
|
|
1559
|
+
show: false,
|
|
1560
|
+
...windowFrame(),
|
|
1561
|
+
webPreferences: {
|
|
1562
|
+
preload,
|
|
1563
|
+
nodeIntegration: false,
|
|
1564
|
+
contextIsolation: true,
|
|
1565
|
+
sandbox: true,
|
|
1566
|
+
webSecurity: true,
|
|
1567
|
+
additionalArguments: [`${DESKTOP_SCHEME_ARGUMENT}=${SCHEME}`]
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
1571
|
+
window.webContents.on("will-navigate", (event, url) => {
|
|
1572
|
+
if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault();
|
|
1573
|
+
});
|
|
1574
|
+
return window;
|
|
1575
|
+
}
|
|
1576
|
+
function developmentHostInspectPort(enabled) {
|
|
1577
|
+
const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT;
|
|
1578
|
+
if (!enabled || configured === void 0 || configured === "") return void 0;
|
|
1579
|
+
const port = Number(configured);
|
|
1580
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("dsh desktop: DSH_DESKTOP_HOST_INSPECT_PORT must be an integer from 1 through 65535");
|
|
1581
|
+
return port;
|
|
1582
|
+
}
|
|
1583
|
+
function developmentNodeArgs() {
|
|
1584
|
+
const specifier = process.env.DSH_DESKTOP_TSX_IMPORT;
|
|
1585
|
+
return specifier === void 0 || specifier === "" ? [] : [`--import=${specifier}`];
|
|
1586
|
+
}
|
|
1587
|
+
async function main() {
|
|
1588
|
+
const development = developmentProject();
|
|
1589
|
+
const resources = runtimeResources();
|
|
1590
|
+
const hostInspectPort = developmentHostInspectPort(development !== void 0);
|
|
1591
|
+
const activeProject = development ?? join(resolveDshHome(appConfig) ?? "", "profiles", "desktop");
|
|
1592
|
+
const runtimeProject = development ?? resources.runtime;
|
|
1593
|
+
const webDocumentRoot = join(runtimeProject, "node_modules", ...WEB_FRONTEND_PACKAGE.split("/"), "dist");
|
|
1594
|
+
const dshHome = resolveDshHome(appConfig);
|
|
1595
|
+
if (development === void 0) {
|
|
1596
|
+
if (dshHome === void 0) throw new Error("dsh desktop: packaged applications require a concrete dshHome");
|
|
1597
|
+
if (await ensureSeedProfile(resources.seed, dshHome)) await installPlantedProfile(activeProject, resources);
|
|
1598
|
+
}
|
|
1599
|
+
if (!await pathExists(join(webDocumentRoot, "index.html"))) throw new Error(`dsh desktop: the Web frontend is missing at ${webDocumentRoot}; the runtime closure must carry ${WEB_FRONTEND_PACKAGE}`);
|
|
1600
|
+
let host;
|
|
1601
|
+
let mainWindow;
|
|
1602
|
+
let quitConfirmed = false;
|
|
1603
|
+
let quitPrompting = false;
|
|
1604
|
+
const appPreload = fileURLToPath(new URL("./preload-app.cjs", import.meta.url));
|
|
1605
|
+
const confirmQuit = async (window) => {
|
|
1606
|
+
if (quitPrompting) return;
|
|
1607
|
+
quitPrompting = true;
|
|
1608
|
+
try {
|
|
1609
|
+
const options = {
|
|
1610
|
+
type: "question",
|
|
1611
|
+
buttons: ["Cancel", "Quit"],
|
|
1612
|
+
defaultId: 0,
|
|
1613
|
+
cancelId: 0,
|
|
1614
|
+
noLink: true,
|
|
1615
|
+
message: `Quit ${appConfig.name}?`,
|
|
1616
|
+
detail: "The desktop backend and its running sessions will stop."
|
|
1617
|
+
};
|
|
1618
|
+
const parent = window ?? mainWindow;
|
|
1619
|
+
const { response } = parent === void 0 || parent.isDestroyed() ? await dialog.showMessageBox(options) : await dialog.showMessageBox(parent, options);
|
|
1620
|
+
if (response !== 1) return;
|
|
1621
|
+
quitConfirmed = true;
|
|
1622
|
+
app.quit();
|
|
1623
|
+
} finally {
|
|
1624
|
+
quitPrompting = false;
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
let hostFailureHandled = false;
|
|
1628
|
+
const handleHostFailure = (error) => {
|
|
1629
|
+
if (hostFailureHandled) return;
|
|
1630
|
+
hostFailureHandled = true;
|
|
1631
|
+
console.error(`[dsh-shell] backend stopped: ${error.message}`);
|
|
1632
|
+
dialog.showErrorBox("dsh desktop backend stopped", error.message);
|
|
1633
|
+
quitConfirmed = true;
|
|
1634
|
+
app.exit(1);
|
|
1635
|
+
};
|
|
1636
|
+
const startHost = async (projectDir = activeProject) => {
|
|
1637
|
+
const next = new DesktopHostProcess(resources.node, runtimeProject, projectDir, hostInspectPort, {
|
|
1638
|
+
nodeArgs: development === void 0 ? [] : developmentNodeArgs(),
|
|
1639
|
+
extraEnv: {
|
|
1640
|
+
...dshHome === void 0 ? {} : { DSH_HOME: dshHome },
|
|
1641
|
+
...development === void 0 ? {} : { ELECTRON_RUN_AS_NODE: "1" }
|
|
1642
|
+
},
|
|
1643
|
+
primaryRuntime: resources.primaryRuntime,
|
|
1644
|
+
processTitle: `${appConfig.name}-server`,
|
|
1645
|
+
...development === void 0 ? { packageManager: {
|
|
1646
|
+
pnpm: resources.pnpm,
|
|
1647
|
+
nodeBin: resources.nodeBin
|
|
1648
|
+
} } : {},
|
|
1649
|
+
spawn: shellWrappedSpawn,
|
|
1650
|
+
onFailure: handleHostFailure
|
|
1651
|
+
});
|
|
1652
|
+
await next.start();
|
|
1653
|
+
return next;
|
|
1654
|
+
};
|
|
1655
|
+
protocol.handle(SCHEME, async (request) => {
|
|
1656
|
+
const url = new URL(request.url);
|
|
1657
|
+
if (url.hostname !== "app") return new Response(null, { status: 404 });
|
|
1658
|
+
const active = host;
|
|
1659
|
+
if (active === void 0) return new Response("backend unavailable", { status: 503 });
|
|
1660
|
+
if (!app.isPackaged) console.error(`[dsh-shell] recv ${request.method} ${url.pathname}`);
|
|
1661
|
+
const response = await active.fetch(request);
|
|
1662
|
+
if (!app.isPackaged) console.error(`[dsh-shell] ${request.method} ${url.pathname} → ${String(response.status)}`);
|
|
1663
|
+
return response;
|
|
1664
|
+
});
|
|
1665
|
+
installDesktopDirectoryPicker(() => mainWindow, SCHEME);
|
|
1666
|
+
let nextStreamId = 1;
|
|
1667
|
+
const streamEncoder = new TextEncoder();
|
|
1668
|
+
const activeStreams = /* @__PURE__ */ new Map();
|
|
1669
|
+
/** 结束宿主请求体:已取消/已结束的流上 close 会抛,这里只当收尾。 */
|
|
1670
|
+
const closeStreamUplink = (stream) => {
|
|
1671
|
+
const uplink = stream.uplink;
|
|
1672
|
+
if (uplink === void 0) return;
|
|
1673
|
+
stream.uplink = void 0;
|
|
1674
|
+
try {
|
|
1675
|
+
uplink.close();
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
if (!app.isPackaged) console.error("[dsh-shell] stream uplink close failed", error);
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
ipcMain.handle(DESKTOP_IPC.streamOpen, async (event, endpoint, payload) => {
|
|
1681
|
+
assertDesktopSender(event, SCHEME, ["app"]);
|
|
1682
|
+
if (typeof endpoint !== "string") throw new Error("dsh desktop: stream endpoint must be text");
|
|
1683
|
+
const active = host;
|
|
1684
|
+
if (active === void 0) throw new Error("dsh desktop: Host is unavailable");
|
|
1685
|
+
if (!app.isPackaged) console.error(`[dsh-shell] stream open ${endpoint}`);
|
|
1686
|
+
const id = nextStreamId++;
|
|
1687
|
+
const abort = new AbortController();
|
|
1688
|
+
const stream = {
|
|
1689
|
+
abort,
|
|
1690
|
+
uplink: void 0
|
|
1691
|
+
};
|
|
1692
|
+
const body = new ReadableStream({
|
|
1693
|
+
start: (controller) => {
|
|
1694
|
+
stream.uplink = controller;
|
|
1695
|
+
controller.enqueue(streamEncoder.encode(encodeDesktopStreamOpen(endpoint, payload)));
|
|
1696
|
+
},
|
|
1697
|
+
cancel: () => {
|
|
1698
|
+
stream.uplink = void 0;
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
activeStreams.set(id, stream);
|
|
1702
|
+
const request = new Request(`http://127.0.0.1${DESKTOP_STREAM_PATH}`, {
|
|
1703
|
+
method: "POST",
|
|
1704
|
+
headers: { "content-type": "application/json" },
|
|
1705
|
+
body,
|
|
1706
|
+
signal: abort.signal,
|
|
1707
|
+
duplex: "half"
|
|
1708
|
+
});
|
|
1709
|
+
(async () => {
|
|
1710
|
+
try {
|
|
1711
|
+
const response = await active.fetch(request);
|
|
1712
|
+
if (!app.isPackaged) console.error(`[dsh-shell] stream ${endpoint} → ${String(response.status)}`);
|
|
1713
|
+
event.sender.send(DESKTOP_IPC.streamChunk, {
|
|
1714
|
+
id,
|
|
1715
|
+
status: response.status
|
|
1716
|
+
});
|
|
1717
|
+
if (response.body === null) {
|
|
1718
|
+
event.sender.send(DESKTOP_IPC.streamEnd, { id });
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
const decoder = new TextDecoder();
|
|
1722
|
+
for await (const chunk of response.body) event.sender.send(DESKTOP_IPC.streamChunk, {
|
|
1723
|
+
id,
|
|
1724
|
+
data: decoder.decode(chunk)
|
|
1725
|
+
});
|
|
1726
|
+
event.sender.send(DESKTOP_IPC.streamEnd, { id });
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
event.sender.send(DESKTOP_IPC.streamError, {
|
|
1729
|
+
id,
|
|
1730
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1731
|
+
});
|
|
1732
|
+
} finally {
|
|
1733
|
+
closeStreamUplink(stream);
|
|
1734
|
+
activeStreams.delete(id);
|
|
1735
|
+
}
|
|
1736
|
+
})();
|
|
1737
|
+
return id;
|
|
1738
|
+
});
|
|
1739
|
+
ipcMain.on(DESKTOP_IPC.streamCancel, (event, id) => {
|
|
1740
|
+
if (mainWindow === void 0 || event.sender !== mainWindow.webContents) return;
|
|
1741
|
+
if (typeof id !== "number") return;
|
|
1742
|
+
activeStreams.get(id)?.abort.abort();
|
|
1743
|
+
});
|
|
1744
|
+
ipcMain.on(DESKTOP_IPC.streamUplink, (event, id, item) => {
|
|
1745
|
+
if (mainWindow === void 0 || event.sender !== mainWindow.webContents) return;
|
|
1746
|
+
if (typeof id !== "number") return;
|
|
1747
|
+
const uplink = activeStreams.get(id)?.uplink;
|
|
1748
|
+
if (uplink === void 0) return;
|
|
1749
|
+
try {
|
|
1750
|
+
uplink.enqueue(streamEncoder.encode(encodeDesktopStreamItem(item)));
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
if (!app.isPackaged) console.error("[dsh-shell] stream uplink rejected", error);
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
ipcMain.on(DESKTOP_IPC.streamUplinkEnd, (event, id) => {
|
|
1756
|
+
if (mainWindow === void 0 || event.sender !== mainWindow.webContents) return;
|
|
1757
|
+
if (typeof id !== "number") return;
|
|
1758
|
+
const stream = activeStreams.get(id);
|
|
1759
|
+
if (stream === void 0) return;
|
|
1760
|
+
closeStreamUplink(stream);
|
|
1761
|
+
});
|
|
1762
|
+
ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source) => {
|
|
1763
|
+
if (mainWindow === void 0 || event.sender !== mainWindow.webContents) return;
|
|
1764
|
+
if (source === "light" || source === "dark" || source === "system") nativeTheme.themeSource = source;
|
|
1765
|
+
});
|
|
1766
|
+
const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath("userData"), process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux", SCHEME);
|
|
1767
|
+
app.on("will-quit", () => {
|
|
1768
|
+
shortcuts.dispose();
|
|
1769
|
+
});
|
|
1770
|
+
const createMainWindow = () => {
|
|
1771
|
+
const window = createWindow(appPreload, appConfig.window);
|
|
1772
|
+
mainWindow = window;
|
|
1773
|
+
shortcuts.attach(window);
|
|
1774
|
+
window.once("ready-to-show", () => {
|
|
1775
|
+
if (!window.isDestroyed()) window.show();
|
|
1776
|
+
});
|
|
1777
|
+
window.on("close", (event) => {
|
|
1778
|
+
if (!app.isPackaged || quitConfirmed) return;
|
|
1779
|
+
event.preventDefault();
|
|
1780
|
+
confirmQuit(window);
|
|
1781
|
+
});
|
|
1782
|
+
window.on("closed", () => {
|
|
1783
|
+
if (mainWindow === window) mainWindow = void 0;
|
|
1784
|
+
});
|
|
1785
|
+
return window;
|
|
1786
|
+
};
|
|
1787
|
+
focusPrimaryWindow = () => {
|
|
1788
|
+
const window = mainWindow;
|
|
1789
|
+
if (window === void 0 || window.isDestroyed()) {
|
|
1790
|
+
createMainWindow().loadURL(APPLICATION_URL);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
if (window.isMinimized()) window.restore();
|
|
1794
|
+
window.show();
|
|
1795
|
+
window.focus();
|
|
1796
|
+
};
|
|
1797
|
+
host = await startHost();
|
|
1798
|
+
mainWindow = createMainWindow();
|
|
1799
|
+
await mainWindow.loadURL(APPLICATION_URL);
|
|
1800
|
+
if (development !== void 0 && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== "0") mainWindow.webContents.openDevTools({ mode: "detach" });
|
|
1801
|
+
app.on("activate", () => {
|
|
1802
|
+
if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow();
|
|
1803
|
+
});
|
|
1804
|
+
app.on("window-all-closed", () => {
|
|
1805
|
+
app.quit();
|
|
1806
|
+
});
|
|
1807
|
+
app.on("before-quit", (event) => {
|
|
1808
|
+
if (app.isPackaged && !quitConfirmed) {
|
|
1809
|
+
event.preventDefault();
|
|
1810
|
+
confirmQuit();
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
if (host === void 0) return;
|
|
1814
|
+
event.preventDefault();
|
|
1815
|
+
const active = host;
|
|
1816
|
+
host = void 0;
|
|
1817
|
+
active.stop().catch((error) => {
|
|
1818
|
+
console.error(error);
|
|
1819
|
+
}).finally(() => {
|
|
1820
|
+
app.quit();
|
|
1821
|
+
});
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
if (app.isPackaged) try {
|
|
1825
|
+
const profile = join(app.getPath("appData"), appConfig.id);
|
|
1826
|
+
await mkdir(profile, { recursive: true });
|
|
1827
|
+
app.setPath("userData", profile);
|
|
1828
|
+
} catch (error) {
|
|
1829
|
+
console.error(error);
|
|
1830
|
+
}
|
|
1831
|
+
if ((() => {
|
|
1832
|
+
if (!app.requestSingleInstanceLock()) {
|
|
1833
|
+
app.quit();
|
|
1834
|
+
return false;
|
|
1835
|
+
}
|
|
1836
|
+
app.on("second-instance", () => {
|
|
1837
|
+
focusPrimaryWindow();
|
|
1838
|
+
});
|
|
1839
|
+
return true;
|
|
1840
|
+
})()) app.whenReady().then(main).catch(async (error) => {
|
|
1841
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1842
|
+
console.error(error);
|
|
1843
|
+
const diagnosticFile = process.env.DSH_DESKTOP_DIAGNOSTIC_FILE;
|
|
1844
|
+
if (diagnosticFile !== void 0) await import("node:fs/promises").then((fs) => fs.writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`)).catch(() => void 0);
|
|
1845
|
+
dialog.showErrorBox("dsh desktop startup failed", message);
|
|
1846
|
+
app.exit(1);
|
|
1847
|
+
});
|
|
1848
|
+
//#endregion
|
|
1849
|
+
export {};
|