@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/LICENSE +21 -0
- package/README.md +73 -2
- package/cordis.patch.yml +3 -0
- package/lib/client.js +646 -0
- package/lib/index.js +459 -0
- package/lib/platform-win.js +270 -0
- package/package.json +48 -4
|
@@ -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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|