@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.
@@ -0,0 +1,270 @@
1
+ // Windows side of the file menu backend. index.js picks these functions when process.platform is "win32".
2
+ // Scripts run through powershell.exe with -EncodedCommand. Paths and other values go in environment
3
+ // variables, never into the script text, so quotes and special characters in file names cannot break the command.
4
+ import { execFile, spawn } from "child_process";
5
+ import crypto from "crypto";
6
+ import fs from "fs";
7
+ import path from "path";
8
+
9
+ const PS_ARGS = [
10
+ "-NoProfile",
11
+ "-NonInteractive",
12
+ "-STA",
13
+ "-WindowStyle",
14
+ "Hidden",
15
+ "-ExecutionPolicy",
16
+ "Bypass",
17
+ "-EncodedCommand"
18
+ ];
19
+
20
+ function getCleanEnv(extraEnv = {}) {
21
+ const env = { ...process.env, ...extraEnv };
22
+ // Crucial: Electron sets ELECTRON_RUN_AS_NODE. If passed to Electron-based apps
23
+ // like VS Code or Trae, they boot as CLI Node.js instead of opening their GUI.
24
+ delete env.ELECTRON_RUN_AS_NODE;
25
+ delete env.ELECTRON_NO_ASAR;
26
+ delete env.NODE_OPTIONS;
27
+ return env;
28
+ }
29
+
30
+ function runPs(script, env, timeout) {
31
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
32
+ return new Promise((resolve, reject) => {
33
+ execFile(
34
+ "powershell.exe",
35
+ [...PS_ARGS, encoded],
36
+ { env: getCleanEnv(env), timeout, windowsHide: true, maxBuffer: 16 * 1024 * 1024 },
37
+ (err, stdout) => (err ? reject(err) : resolve(String(stdout)))
38
+ );
39
+ });
40
+ }
41
+
42
+ // Lists the apps registered for a file extension: the user's choice first, then OpenWithProgids and OpenWithList.
43
+ const LIST_SCRIPT = String.raw`
44
+ try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
45
+ $ErrorActionPreference = 'SilentlyContinue'
46
+ $ext = $env:FCM_EXT
47
+ $base = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\' + $ext
48
+ $blocked = @('rundll32.exe', 'dllhost.exe', 'msiexec.exe')
49
+
50
+ function Get-ExeFromCommand([string]$cmd) {
51
+ if ([string]::IsNullOrWhiteSpace($cmd)) { return $null }
52
+ $c = [Environment]::ExpandEnvironmentVariables($cmd.Trim())
53
+ if ($c.StartsWith('"')) {
54
+ $end = $c.IndexOf('"', 1)
55
+ if ($end -lt 2) { return $null }
56
+ $exe = $c.Substring(1, $end - 1)
57
+ } elseif ($c -match '^(\S+?\.exe)(\s|$)') {
58
+ $exe = $Matches[1]
59
+ } else { return $null }
60
+ if (Test-Path -LiteralPath $exe -PathType Leaf) { return $exe }
61
+ return $null
62
+ }
63
+
64
+ function Get-ProgCommand([string]$progId) {
65
+ $item = Get-Item -LiteralPath ('Registry::HKEY_CLASSES_ROOT\' + $progId + '\shell\open\command') -ErrorAction SilentlyContinue
66
+ if ($item) { return [string]$item.GetValue('') }
67
+ return $null
68
+ }
69
+
70
+ function Get-ExeFromName([string]$name) {
71
+ foreach ($root in @('HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\', 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\')) {
72
+ $it = Get-Item -LiteralPath ($root + $name) -ErrorAction SilentlyContinue
73
+ if ($it) {
74
+ $v = [Environment]::ExpandEnvironmentVariables([string]$it.GetValue(''))
75
+ if ($v -and (Test-Path -LiteralPath $v -PathType Leaf)) { return $v }
76
+ }
77
+ }
78
+ $cmd = Get-Item -LiteralPath ('Registry::HKEY_CLASSES_ROOT\Applications\' + $name + '\shell\open\command') -ErrorAction SilentlyContinue
79
+ if ($cmd) { return Get-ExeFromCommand ([string]$cmd.GetValue('')) }
80
+ return $null
81
+ }
82
+
83
+ $defaultProg = $null
84
+ $uc = Get-Item -LiteralPath ($base + '\UserChoice') -ErrorAction SilentlyContinue
85
+ if ($uc) { $defaultProg = [string]$uc.GetValue('ProgId') }
86
+
87
+ $progIds = New-Object System.Collections.Generic.List[string]
88
+ if ($defaultProg) { $progIds.Add($defaultProg) }
89
+ $owp = Get-Item -LiteralPath ($base + '\OpenWithProgids') -ErrorAction SilentlyContinue
90
+ if ($owp) {
91
+ foreach ($n in $owp.GetValueNames()) {
92
+ if ($n -and -not $progIds.Contains($n)) { $progIds.Add($n) }
93
+ }
94
+ }
95
+
96
+ $exeNames = New-Object System.Collections.Generic.List[string]
97
+ $owl = Get-Item -LiteralPath ($base + '\OpenWithList') -ErrorAction SilentlyContinue
98
+ if ($owl) {
99
+ foreach ($n in $owl.GetValueNames()) {
100
+ if ($n -match '^[a-z]$') {
101
+ $v = [string]$owl.GetValue($n)
102
+ if ($v) { $exeNames.Add($v) }
103
+ }
104
+ }
105
+ }
106
+
107
+ $seen = @{}
108
+ $apps = New-Object System.Collections.ArrayList
109
+ $defaultExe = ''
110
+
111
+ function Add-App([string]$exe) {
112
+ if (-not $exe) { return }
113
+ if ($blocked -contains [System.IO.Path]::GetFileName($exe).ToLowerInvariant()) { return }
114
+ $key = $exe.ToLowerInvariant()
115
+ if ($script:seen.ContainsKey($key)) { return }
116
+ $script:seen[$key] = $true
117
+ $info = (Get-Item -LiteralPath $exe).VersionInfo
118
+ $name = [string]$info.FileDescription
119
+ if ([string]::IsNullOrWhiteSpace($name)) { $name = [System.IO.Path]::GetFileNameWithoutExtension($exe) }
120
+ [void]$script:apps.Add(@{ exe = $exe; name = $name.Trim() })
121
+ }
122
+
123
+ foreach ($prog in $progIds) {
124
+ $exe = Get-ExeFromCommand (Get-ProgCommand $prog)
125
+ if ($exe) {
126
+ if ($prog -eq $defaultProg -and -not $defaultExe) { $defaultExe = $exe }
127
+ Add-App $exe
128
+ }
129
+ }
130
+ foreach ($n in $exeNames) { Add-App (Get-ExeFromName $n) }
131
+
132
+ $result = @{ defaultExe = [string]$defaultExe; apps = @($apps.ToArray()) }
133
+ ConvertTo-Json -InputObject $result -Depth 4 -Compress
134
+ `;
135
+
136
+ // Writes one 32x32 PNG per job. Each job is { exe, out }.
137
+ const ICON_SCRIPT = String.raw`
138
+ try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
139
+ Add-Type -AssemblyName System.Drawing
140
+ $jobs = @(ConvertFrom-Json -InputObject $env:FCM_ICON_JOBS)
141
+ foreach ($j in $jobs) {
142
+ try {
143
+ $icon = [System.Drawing.Icon]::ExtractAssociatedIcon([string]$j.exe)
144
+ if ($null -eq $icon) { continue }
145
+ $src = $icon.ToBitmap()
146
+ $bmp = New-Object System.Drawing.Bitmap -ArgumentList 32, 32
147
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
148
+ $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
149
+ $g.DrawImage($src, 0, 0, 32, 32)
150
+ $g.Dispose()
151
+ $bmp.Save([string]$j.out, [System.Drawing.Imaging.ImageFormat]::Png)
152
+ $bmp.Dispose()
153
+ $src.Dispose()
154
+ $icon.Dispose()
155
+ } catch {}
156
+ }
157
+ `;
158
+
159
+ // Shows the system save dialog. Prints the chosen path, or nothing when the user cancels.
160
+ const SAVE_SCRIPT = String.raw`
161
+ try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
162
+ Add-Type -AssemblyName System.Windows.Forms
163
+ Add-Type -AssemblyName System.Drawing
164
+ $owner = New-Object System.Windows.Forms.Form
165
+ $owner.TopMost = $true
166
+ $owner.ShowInTaskbar = $false
167
+ $owner.StartPosition = 'CenterScreen'
168
+ $owner.Size = New-Object System.Drawing.Size -ArgumentList 1, 1
169
+ $owner.Opacity = 0
170
+ $owner.Show()
171
+ $owner.Activate()
172
+ $dlg = New-Object System.Windows.Forms.SaveFileDialog
173
+ $dlg.Title = '另存为'
174
+ $dlg.FileName = $env:FCM_DEFAULT_NAME
175
+ $dlg.Filter = 'All files (*.*)|*.*'
176
+ $dlg.OverwritePrompt = $true
177
+ $answer = $dlg.ShowDialog($owner)
178
+ if ($answer -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($dlg.FileName) }
179
+ $owner.Dispose()
180
+ `;
181
+
182
+ // Opens a file with its default app, the same as double-clicking it in Explorer.
183
+ const OPEN_SCRIPT = String.raw`
184
+ [void][System.Diagnostics.Process]::Start($env:FCM_FILE)
185
+ `;
186
+
187
+ export async function listAppsWin(filePath) {
188
+ const ext = path.extname(filePath).toLowerCase();
189
+ if (!ext) return { defaultExe: "", apps: [] };
190
+ const out = (await runPs(LIST_SCRIPT, { FCM_EXT: ext }, 15000)).trim();
191
+ if (!out) return { defaultExe: "", apps: [] };
192
+ const parsed = JSON.parse(out);
193
+ const apps = Array.isArray(parsed.apps) ? parsed.apps : [];
194
+ return { defaultExe: parsed.defaultExe || "", apps: apps.map((a) => ({ exe: a.exe, name: a.name })) };
195
+ }
196
+
197
+ function iconCachePath(cacheDir, exe) {
198
+ return path.join(cacheDir, crypto.createHash("md5").update(exe).digest("hex") + ".png");
199
+ }
200
+
201
+ // Returns a Map from exe path to a PNG data URL. Missing icons are extracted in one PowerShell run.
202
+ export async function iconsWin(exePaths, cacheDir) {
203
+ const jobs = exePaths
204
+ .filter((exe) => !fs.existsSync(iconCachePath(cacheDir, exe)))
205
+ .map((exe) => ({ exe, out: iconCachePath(cacheDir, exe) }));
206
+ if (jobs.length > 0) {
207
+ try {
208
+ await runPs(ICON_SCRIPT, { FCM_ICON_JOBS: JSON.stringify(jobs) }, 20000);
209
+ } catch {}
210
+ // An empty file marks an icon that could not be extracted, so later requests do not retry it.
211
+ for (const job of jobs) {
212
+ if (!fs.existsSync(job.out)) {
213
+ try {
214
+ fs.writeFileSync(job.out, "");
215
+ } catch {}
216
+ }
217
+ }
218
+ }
219
+ const result = new Map();
220
+ for (const exe of exePaths) {
221
+ try {
222
+ const buf = fs.readFileSync(iconCachePath(cacheDir, exe));
223
+ result.set(exe, buf.length > 0 ? "data:image/png;base64," + buf.toString("base64") : "");
224
+ } catch {
225
+ result.set(exe, "");
226
+ }
227
+ }
228
+ return result;
229
+ }
230
+
231
+ // Returns the chosen path, or "" when the user cancels.
232
+ export async function saveAsWin(defaultName) {
233
+ const out = await runPs(SAVE_SCRIPT, { FCM_DEFAULT_NAME: defaultName }, 0);
234
+ return out.trim();
235
+ }
236
+
237
+ export function openDefaultWin(filePath) {
238
+ return runPs(OPEN_SCRIPT, { FCM_FILE: filePath }, 10000).then(() => undefined);
239
+ }
240
+
241
+ export function openWithWin(appExe, filePath) {
242
+ return new Promise((resolve, reject) => {
243
+ const child = spawn(appExe, [filePath], {
244
+ detached: true,
245
+ stdio: "ignore",
246
+ env: getCleanEnv()
247
+ });
248
+ child.on("error", reject);
249
+ child.on("spawn", () => {
250
+ child.unref();
251
+ resolve();
252
+ });
253
+ });
254
+ }
255
+
256
+ // explorer.exe exits with code 1 even when the window opens, so only a spawn failure counts as an error.
257
+ export function revealWin(filePath) {
258
+ return new Promise((resolve, reject) => {
259
+ const child = spawn("explorer.exe", [`/select,"${filePath}"`], {
260
+ detached: true,
261
+ stdio: "ignore",
262
+ windowsVerbatimArguments: true
263
+ });
264
+ child.on("error", reject);
265
+ child.on("spawn", () => {
266
+ child.unref();
267
+ resolve();
268
+ });
269
+ });
270
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@dengsixue/dsh-file-menu",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Right-click menu for file links in the DeepSeek Harness chat: open with, save as, copy path, copy content, reveal in Finder or Explorer.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "dsh",
11
+ "dsh-plugin",
12
+ "dsh-bundle",
13
+ "deepseek-harness",
14
+ "file",
15
+ "context-menu",
16
+ "ui"
17
+ ],
18
+ "main": "./lib/index.js",
19
+ "type": "module",
20
+ "exports": {
21
+ ".": "./lib/index.js",
22
+ "./client": "./lib/client.js"
23
+ },
24
+ "files": [
25
+ "lib",
26
+ "cordis.patch.yml",
27
+ "package.json",
28
+ "README.md"
29
+ ],
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ },
34
+ "client": {
35
+ "platform": "web",
36
+ "immediately": true,
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-ui-conversation"
39
+ ]
40
+ }
41
+ },
42
+ "peerDependencies": {
43
+ "@deepseek-ai/cordis": "^4.0.1"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@deepseek-ai/cordis": {
47
+ "optional": true
48
+ }
49
+ }
50
+ }