@dengsixue/dsh-file-menu 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,459 @@
1
+ import { exec, execFile, execFileSync } from "child_process";
2
+ import crypto from "crypto";
3
+ import { promisify } from "util";
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import os from "os";
7
+ import * as win from "./platform-win.js";
8
+
9
+ const IS_WIN = process.platform === "win32";
10
+ const execAsync = promisify(exec);
11
+ const execFileAsync = promisify(execFile);
12
+ const BRIDGE_PREFIX = "/api/dsh-file-menu";
13
+
14
+ const ICONS_CACHE_DIR = path.join(os.tmpdir(), "dsh-fcm-icons");
15
+ try {
16
+ fs.mkdirSync(ICONS_CACHE_DIR, { recursive: true });
17
+ } catch {}
18
+
19
+ const STATE_DIR = IS_WIN
20
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "dsh-file-menu")
21
+ : path.join(os.homedir(), "Library", "Application Support", "dsh-file-menu");
22
+ const STATE_FILE = path.join(STATE_DIR, "state.json");
23
+ const JXA_PATH = path.join(STATE_DIR, "list-apps.js");
24
+
25
+ // macOS only: apps that sit directly in these folders are offered in the menu.
26
+ const APP_ROOTS = [
27
+ "/Applications",
28
+ "/System/Applications",
29
+ "/System/Applications/Utilities",
30
+ path.join(os.homedir(), "Applications")
31
+ ];
32
+ const MAX_APPS = 12;
33
+ const APP_CACHE_TTL_MS = 10 * 60 * 1000;
34
+ const appListCache = new Map();
35
+
36
+ // JXA asks macOS LaunchServices which apps can open a file, the same list Finder shows in "打开方式".
37
+ const JXA_SOURCE = `ObjC.import('AppKit');
38
+ ObjC.import('Foundation');
39
+ function run(argv) {
40
+ const ws = $.NSWorkspace.sharedWorkspace;
41
+ const fm = $.NSFileManager.defaultManager;
42
+ const url = $.NSURL.fileURLWithPath(argv[0]);
43
+ const def = ws.URLForApplicationToOpenURL(url);
44
+ const list = ws.URLsForApplicationsToOpenURL(url);
45
+ const apps = [];
46
+ for (let i = 0; i < list.count; i++) {
47
+ const p = list.objectAtIndex(i).path.js;
48
+ apps.push({ path: p, name: fm.displayNameAtPath(p).js });
49
+ }
50
+ return JSON.stringify({ defaultPath: def ? def.path.js : "", apps: apps });
51
+ }
52
+ `;
53
+
54
+ let jxaWritten = false;
55
+ function ensureJxa() {
56
+ if (jxaWritten) return;
57
+ fs.mkdirSync(STATE_DIR, { recursive: true });
58
+ fs.writeFileSync(JXA_PATH, JXA_SOURCE);
59
+ jxaWritten = true;
60
+ }
61
+
62
+ function normalizeAppPath(p) {
63
+ // Safari is reported under the Cryptex volume; its real location is /System/Applications.
64
+ return p.replace(/^\/System\/Volumes\/Preboot\/Cryptexes\/App(?=\/System\/)/, "");
65
+ }
66
+
67
+ function isAllowedApp(p) {
68
+ return p.endsWith(".app") && APP_ROOTS.includes(path.dirname(p)) && fs.existsSync(p);
69
+ }
70
+
71
+ function findIcnsFile(bundlePath) {
72
+ const resDir = path.join(bundlePath, "Contents", "Resources");
73
+ let declared = "";
74
+ try {
75
+ declared = execFileSync("/usr/bin/defaults", ["read", path.join(bundlePath, "Contents", "Info.plist"), "CFBundleIconFile"], {
76
+ encoding: "utf8",
77
+ stdio: ["ignore", "pipe", "ignore"],
78
+ timeout: 2000
79
+ }).trim();
80
+ } catch {}
81
+ if (declared) {
82
+ const name = declared.endsWith(".icns") ? declared : `${declared}.icns`;
83
+ const p = path.join(resDir, name);
84
+ if (fs.existsSync(p)) return p;
85
+ }
86
+ try {
87
+ const f = fs.readdirSync(resDir).find((x) => x.endsWith(".icns"));
88
+ return f ? path.join(resDir, f) : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ // macOS: returns the app icon as a 32px PNG data URL, cached in the temp directory.
95
+ function iconDataUrl(bundlePath) {
96
+ const key = crypto.createHash("md5").update(bundlePath).digest("hex");
97
+ const cached = path.join(ICONS_CACHE_DIR, `${key}.png`);
98
+ if (!fs.existsSync(cached)) {
99
+ const icns = findIcnsFile(bundlePath);
100
+ if (!icns) return "";
101
+ try {
102
+ execFileSync("/usr/bin/sips", ["-s", "format", "png", "-z", "32", "32", icns, "--out", cached], {
103
+ stdio: "ignore",
104
+ timeout: 3000
105
+ });
106
+ } catch {
107
+ return "";
108
+ }
109
+ }
110
+ try {
111
+ return "data:image/png;base64," + fs.readFileSync(cached).toString("base64");
112
+ } catch {
113
+ return "";
114
+ }
115
+ }
116
+
117
+ async function queryLaunchServices(filePath) {
118
+ ensureJxa();
119
+ const { stdout } = await execFileAsync("/usr/bin/osascript", ["-l", "JavaScript", JXA_PATH, filePath], {
120
+ timeout: 5000
121
+ });
122
+ return JSON.parse(stdout);
123
+ }
124
+
125
+ async function listAppsForFileMac(filePath) {
126
+ const { defaultPath, apps: raw } = await queryLaunchServices(filePath);
127
+ const defNorm = defaultPath ? normalizeAppPath(defaultPath) : "";
128
+ const entries = raw.map((r) => ({ path: normalizeAppPath(r.path), name: r.name }));
129
+ entries.sort((a, b) => (b.path === defNorm) - (a.path === defNorm));
130
+
131
+ const seen = new Set();
132
+ const apps = [];
133
+ for (const e of entries) {
134
+ if (seen.has(e.path) || !isAllowedApp(e.path)) continue;
135
+ seen.add(e.path);
136
+ apps.push({ name: e.name, app: e.path, icon: iconDataUrl(e.path) });
137
+ if (apps.length >= MAX_APPS) break;
138
+ }
139
+ return apps;
140
+ }
141
+
142
+ // Windows: the registry lookup returns the default app first. Icons are extracted in one PowerShell run.
143
+ async function listAppsForFileWin(filePath) {
144
+ const { defaultExe, apps: raw } = await win.listAppsWin(filePath);
145
+ const sorted = [...raw].sort((a, b) => (b.exe === defaultExe) - (a.exe === defaultExe)).slice(0, MAX_APPS);
146
+ const icons = await win.iconsWin(
147
+ sorted.map((a) => a.exe),
148
+ ICONS_CACHE_DIR
149
+ );
150
+ return sorted.map((a) => ({ name: a.name, app: a.exe, icon: icons.get(a.exe) || "" }));
151
+ }
152
+
153
+ // Apps that can open this file type, default app first. Cached per extension for a few minutes.
154
+ async function listAppsForFile(filePath) {
155
+ const ext = path.extname(filePath).toLowerCase();
156
+ const key = ext || filePath;
157
+ const hit = appListCache.get(key);
158
+ if (hit && Date.now() - hit.at < APP_CACHE_TTL_MS) return hit.apps;
159
+
160
+ let result;
161
+ try {
162
+ result = IS_WIN ? await listAppsForFileWin(filePath) : await listAppsForFileMac(filePath);
163
+ } catch {
164
+ return [];
165
+ }
166
+ appListCache.set(key, { at: Date.now(), apps: result });
167
+ return result;
168
+ }
169
+
170
+ // Turns the stored last-used app into a menu entry. The stored value is a bundle path (macOS), an exe path (Windows),
171
+ // or an older bare name such as "Android Studio".
172
+ function resolveAppRef(ref, nameHint) {
173
+ if (!ref || typeof ref !== "string") return null;
174
+ if (IS_WIN) {
175
+ if (!path.isAbsolute(ref) || !ref.toLowerCase().endsWith(".exe") || !fs.existsSync(ref)) return null;
176
+ return { name: nameHint || path.basename(ref, ".exe"), app: ref, icon: "" };
177
+ }
178
+ let bundle = null;
179
+ if (path.isAbsolute(ref)) {
180
+ bundle = normalizeAppPath(ref);
181
+ } else {
182
+ for (const root of APP_ROOTS) {
183
+ const p = path.join(root, `${ref}.app`);
184
+ if (fs.existsSync(p)) {
185
+ bundle = p;
186
+ break;
187
+ }
188
+ }
189
+ }
190
+ if (!bundle || !fs.existsSync(bundle)) return null;
191
+ return { name: nameHint || path.basename(bundle, ".app"), app: bundle, icon: iconDataUrl(bundle) };
192
+ }
193
+
194
+ async function getAvailableApps(filePath) {
195
+ const fileApps = filePath ? await listAppsForFile(filePath) : [];
196
+ const state = readState();
197
+ const last = resolveAppRef(state.lastApp, state.lastAppName);
198
+ if (last && IS_WIN && !last.icon) {
199
+ const icons = await win.iconsWin([last.app], ICONS_CACHE_DIR);
200
+ last.icon = icons.get(last.app) || "";
201
+ }
202
+
203
+ const merged = [];
204
+ const seen = new Set();
205
+ const keyOf = (app) => (IS_WIN ? app.toLowerCase() : app);
206
+ const add = (a) => {
207
+ if (!a) return;
208
+ const k = keyOf(a.app);
209
+ if (seen.has(k)) return;
210
+ seen.add(k);
211
+ merged.push(a);
212
+ };
213
+ add(last);
214
+ fileApps.forEach(add);
215
+ merged.push({ name: "系统默认", app: "", icon: "" });
216
+
217
+ return { apps: merged, lastApp: last ? last.app : "" };
218
+ }
219
+
220
+ function readState() {
221
+ try {
222
+ return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
223
+ } catch {
224
+ return {};
225
+ }
226
+ }
227
+
228
+ function writeState(patch) {
229
+ try {
230
+ fs.mkdirSync(STATE_DIR, { recursive: true });
231
+ fs.writeFileSync(STATE_FILE, JSON.stringify({ ...readState(), ...patch }, null, 2));
232
+ } catch {}
233
+ }
234
+
235
+ function writeJson(res, status, body) {
236
+ const payload = JSON.stringify(body);
237
+ res.writeHead(status, {
238
+ "content-type": "application/json; charset=utf-8",
239
+ "access-control-allow-origin": "*",
240
+ "access-control-allow-methods": "GET, POST, OPTIONS",
241
+ "access-control-allow-headers": "content-type",
242
+ "referrer-policy": "no-referrer"
243
+ });
244
+ res.end(payload);
245
+ }
246
+
247
+ async function readJsonBody(req) {
248
+ const chunks = [];
249
+ for await (const chunk of req) chunks.push(chunk);
250
+ try {
251
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
252
+ } catch {
253
+ return {};
254
+ }
255
+ }
256
+
257
+ function resolveFilePath(rawPath) {
258
+ if (!rawPath || typeof rawPath !== "string") return null;
259
+ let p = rawPath.trim();
260
+
261
+ // Strip prefix/suffix artifacts
262
+ if (p.startsWith("@/")) p = p.slice(1);
263
+ if (p.startsWith("@")) p = p.slice(1);
264
+ p = p.replace(/^<|>$/g, "");
265
+ p = p.replace(/#L\d+(-L\d+)?$/, "").replace(/:\d+(-\d+)?$/, "");
266
+
267
+ // Defensively reject API routes and web protocols
268
+ if (/^\/?api(\/|$)/i.test(p) || /^(https?|ftp|file|ws|wss):/i.test(p)) {
269
+ return null;
270
+ }
271
+
272
+ if (p.startsWith("~/") || p === "~") {
273
+ p = path.join(os.homedir(), p.slice(1));
274
+ } else if (!path.isAbsolute(p)) {
275
+ const cwdCandidate = path.resolve(process.cwd(), p);
276
+ if (fs.existsSync(cwdCandidate)) {
277
+ p = cwdCandidate;
278
+ } else {
279
+ const defaultWs = path.join(os.homedir(), "Documents", "deepseek-harness", "default-workspace", p);
280
+ if (fs.existsSync(defaultWs)) {
281
+ p = defaultWs;
282
+ } else {
283
+ p = cwdCandidate;
284
+ }
285
+ }
286
+ }
287
+ return path.normalize(p);
288
+ }
289
+
290
+ export function apply(ctx) {
291
+ ctx.inject(["webServer"], (sctx) => {
292
+ sctx.effect(() => {
293
+ const dispose = sctx.webServer.register({
294
+ kind: "prefix",
295
+ path: BRIDGE_PREFIX,
296
+ handler: async (req, res) => {
297
+ if (req.method === "OPTIONS") {
298
+ res.writeHead(204, {
299
+ "access-control-allow-origin": "*",
300
+ "access-control-allow-methods": "GET, POST, OPTIONS",
301
+ "access-control-allow-headers": "content-type"
302
+ });
303
+ res.end();
304
+ return;
305
+ }
306
+
307
+ const urlObj = new URL(req.url, "http://localhost");
308
+ const subPath = urlObj.pathname.slice(BRIDGE_PREFIX.length);
309
+
310
+ if (subPath === "/apps") {
311
+ const filePath = resolveFilePath(urlObj.searchParams.get("path"));
312
+ const existing = filePath && fs.existsSync(filePath) ? filePath : null;
313
+ const { apps, lastApp } = await getAvailableApps(existing);
314
+ writeJson(res, 200, { ok: true, apps, lastApp });
315
+ return;
316
+ }
317
+
318
+ if (subPath === "/check") {
319
+ const targetPath = resolveFilePath(urlObj.searchParams.get("path"));
320
+ const exists = !!(targetPath && fs.existsSync(targetPath));
321
+ writeJson(res, 200, { ok: true, exists, path: targetPath });
322
+ return;
323
+ }
324
+
325
+ if (subPath === "/reveal") {
326
+ const body = await readJsonBody(req);
327
+ const targetPath = resolveFilePath(body.path);
328
+ if (!targetPath || !fs.existsSync(targetPath)) {
329
+ writeJson(res, 404, { ok: false, error: "文件不存在: " + (targetPath || body.path) });
330
+ return;
331
+ }
332
+ try {
333
+ if (IS_WIN) {
334
+ await win.revealWin(targetPath);
335
+ } else {
336
+ await execAsync(`/usr/bin/open -R ${JSON.stringify(targetPath)}`);
337
+ }
338
+ writeJson(res, 200, { ok: true, path: targetPath });
339
+ } catch (err) {
340
+ writeJson(res, 500, { ok: false, error: err.message });
341
+ }
342
+ return;
343
+ }
344
+
345
+ if (subPath === "/open") {
346
+ const body = await readJsonBody(req);
347
+ const targetPath = resolveFilePath(body.path);
348
+ if (!targetPath || !fs.existsSync(targetPath)) {
349
+ writeJson(res, 404, { ok: false, error: "文件不存在: " + (targetPath || body.path) });
350
+ return;
351
+ }
352
+ // On Windows the app must be an existing .exe, so the bridge cannot launch arbitrary paths.
353
+ if (IS_WIN && body.app && !(body.app.toLowerCase().endsWith(".exe") && fs.existsSync(body.app))) {
354
+ writeJson(res, 400, { ok: false, error: "应用不存在: " + body.app });
355
+ return;
356
+ }
357
+ try {
358
+ if (IS_WIN) {
359
+ if (body.app) {
360
+ await win.openWithWin(body.app, targetPath);
361
+ } else {
362
+ await win.openDefaultWin(targetPath);
363
+ }
364
+ } else if (body.app) {
365
+ await execAsync(`/usr/bin/open -a ${JSON.stringify(body.app)} ${JSON.stringify(targetPath)}`);
366
+ } else {
367
+ await execAsync(`/usr/bin/open ${JSON.stringify(targetPath)}`);
368
+ }
369
+ if (body.app) writeState({ lastApp: body.app, lastAppName: typeof body.name === "string" ? body.name : "" });
370
+ writeJson(res, 200, { ok: true, path: targetPath });
371
+ } catch (err) {
372
+ writeJson(res, 500, { ok: false, error: err.message });
373
+ }
374
+ return;
375
+ }
376
+
377
+ if (subPath === "/read") {
378
+ const body = await readJsonBody(req);
379
+ const targetPath = resolveFilePath(body.path);
380
+ if (!targetPath || !fs.existsSync(targetPath)) {
381
+ writeJson(res, 404, { ok: false, error: "文件不存在: " + (targetPath || body.path) });
382
+ return;
383
+ }
384
+ try {
385
+ const stat = await fs.promises.stat(targetPath);
386
+ if (stat.isDirectory()) {
387
+ writeJson(res, 400, { ok: false, error: "目标是目录,无法复制文本内容" });
388
+ return;
389
+ }
390
+ if (stat.size > 5 * 1024 * 1024) {
391
+ writeJson(res, 400, { ok: false, error: "文件大于 5MB,暂不支持一次性复制" });
392
+ return;
393
+ }
394
+ const content = await fs.promises.readFile(targetPath, "utf8");
395
+ writeJson(res, 200, { ok: true, content, length: content.length, path: targetPath });
396
+ } catch (err) {
397
+ writeJson(res, 500, { ok: false, error: err.message });
398
+ }
399
+ return;
400
+ }
401
+
402
+ if (subPath === "/save-as") {
403
+ const body = await readJsonBody(req);
404
+ const targetPath = resolveFilePath(body.path);
405
+ if (!targetPath || !fs.existsSync(targetPath)) {
406
+ writeJson(res, 404, { ok: false, error: "文件不存在: " + (targetPath || body.path) });
407
+ return;
408
+ }
409
+ const baseName = path.basename(targetPath);
410
+ try {
411
+ let chosen = "";
412
+ if (IS_WIN) {
413
+ chosen = await win.saveAsWin(baseName);
414
+ } else {
415
+ const script = `set f to choose file name with prompt "另存为" default name ${JSON.stringify(baseName)}\nPOSIX path of f`;
416
+ const { stdout } = await execAsync(`osascript -e ${JSON.stringify(script)}`);
417
+ chosen = stdout.trim();
418
+ }
419
+ if (chosen) {
420
+ await fs.promises.copyFile(targetPath, chosen);
421
+ writeJson(res, 200, { ok: true, savedTo: chosen, baseName });
422
+ } else {
423
+ writeJson(res, 200, { ok: false, canceled: true });
424
+ }
425
+ } catch (err) {
426
+ if (err.message && err.message.includes("User canceled")) {
427
+ writeJson(res, 200, { ok: false, canceled: true });
428
+ } else {
429
+ writeJson(res, 500, { ok: false, error: err.message });
430
+ }
431
+ }
432
+ return;
433
+ }
434
+
435
+ if (subPath === "/download") {
436
+ const targetPath = resolveFilePath(urlObj.searchParams.get("path"));
437
+ if (!targetPath || !fs.existsSync(targetPath)) {
438
+ writeJson(res, 404, { ok: false, error: "文件不存在" });
439
+ return;
440
+ }
441
+ const baseName = path.basename(targetPath);
442
+ res.writeHead(200, {
443
+ "Content-Type": "application/octet-stream",
444
+ "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(baseName)}`
445
+ });
446
+ fs.createReadStream(targetPath).pipe(res);
447
+ return;
448
+ }
449
+
450
+ writeJson(res, 404, { ok: false, error: "未知操作" });
451
+ }
452
+ });
453
+
454
+ return () => {
455
+ if (typeof dispose === "function") dispose();
456
+ };
457
+ }, "dsh-file-menu: bridge route");
458
+ });
459
+ }