dsh-vscode-mode 0.12.0 → 0.13.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.
Files changed (71) hide show
  1. package/README.md +148 -112
  2. package/assets/shell/dsh-open.cs +65 -149
  3. package/assets/shell/dsh-open.mjs +241 -0
  4. package/assets/shell/dsh-open.ps1 +28 -47
  5. package/assets/shell/dsh-open.sh +25 -66
  6. package/lib/client.js +30185 -29711
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +3889 -2336
  9. package/lib/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
  12. package/src/childEnv.ts +19 -0
  13. package/src/client/addToConversation.ts +4 -2
  14. package/src/client/ai/inlineProvider.ts +1 -8
  15. package/src/client/commandBridge.ts +6 -2
  16. package/src/client/externalOpen.ts +40 -19
  17. package/src/client/index.ts +17 -15
  18. package/src/client/keybindings.ts +18 -1
  19. package/src/client/monaco/loader.ts +3 -1
  20. package/src/client/openFlow.ts +36 -28
  21. package/src/client/openReceipt.ts +64 -0
  22. package/src/client/panelCommands.ts +101 -0
  23. package/src/client/pdf/pdfLoader.ts +4 -2
  24. package/src/client/receiptState.ts +120 -0
  25. package/src/client/rpc.ts +30 -4
  26. package/src/client/shortcutsOfficial.ts +39 -4
  27. package/src/client/sidebarBridge.ts +7 -0
  28. package/src/client/styles/mcp.css +1 -19
  29. package/src/client/ui/DiffBadge.ts +2 -1
  30. package/src/client/ui/EditorView.ts +237 -55
  31. package/src/client/ui/McpSettings.ts +32 -7
  32. package/src/client/ui/OfficialSideTab.ts +5 -2
  33. package/src/client/ui/QuickOpen.ts +10 -9
  34. package/src/client/ui/commandCatalog.ts +5 -4
  35. package/src/client/useOpenReceipt.ts +128 -0
  36. package/src/compat.ts +5 -3
  37. package/src/dap/manager.ts +46 -13
  38. package/src/devForm.ts +252 -92
  39. package/src/externalHandoff.ts +93 -52
  40. package/src/fileOpenSettings.ts +1 -24
  41. package/src/hostImport.ts +19 -0
  42. package/src/httpGuard.ts +101 -0
  43. package/src/index.ts +106 -97
  44. package/src/integrate.ts +56 -50
  45. package/src/lsp/transport.ts +84 -127
  46. package/src/mcp.ts +58 -9
  47. package/src/mcpIsolation.ts +211 -102
  48. package/src/mcpProject.ts +220 -133
  49. package/src/mcpRuntime.ts +343 -0
  50. package/src/openBridge.ts +69 -0
  51. package/src/openInbox.ts +207 -0
  52. package/src/paths.ts +8 -5
  53. package/src/processTree.ts +63 -0
  54. package/src/reveal.ts +5 -3
  55. package/src/routes.ts +156 -154
  56. package/src/rpc.ts +14 -13
  57. package/src/shared/appUrl.ts +19 -0
  58. package/src/shared/externalOpen.ts +71 -0
  59. package/src/shared/integration.ts +2 -0
  60. package/src/shared/keybindings.ts +8 -5
  61. package/src/shared/mcp.ts +4 -1
  62. package/src/shared/rpc.ts +7 -4
  63. package/src/shared/svn.ts +6 -8
  64. package/src/svnPlanTool.ts +51 -0
  65. package/src/unityBridge.ts +26 -3
  66. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
  67. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
  68. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
  69. package/unity/com.dsh.editor/README.md +16 -25
  70. package/unity/com.dsh.editor/package.json +2 -2
  71. package/src/client/ui/ShortcutSettings.ts +0 -294
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ // Shared OPEN REQUEST/ACK producer. No HTTP/RPC or browser credentials.
3
+ import { constants } from 'node:fs';
4
+ import { lstat, open, readFile, realpath, rename, unlink } from 'node:fs/promises';
5
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const MAX_BYTES = 64 * 1024;
11
+ const TTL = 60_000;
12
+
13
+ /** @author ddj 2026-09-28 @param {string} file INI path @returns {Promise<object>} Validated bridge metadata. */
14
+ export async function readConfig(file) {
15
+ if (!isAbsolute(file)) throw new Error('Bridge configuration must be an absolute path.');
16
+ const stat = await lstat(file);
17
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) throw new Error('Invalid bridge configuration.');
18
+ const config = Object.create(null);
19
+ for (const line of (await readFile(file, 'utf8')).split(/\r?\n/)) {
20
+ const match = /^\s*([a-zA-Z]+)\s*=(.*)$/.exec(line);
21
+ if (match) config[match[1].toLowerCase()] = match[2].trim();
22
+ }
23
+ for (const key of ['profile', 'inbox']) {
24
+ if (!config[key] || !isAbsolute(config[key]) || config[key].length > 8192) throw new Error('Missing absolute ' + key + ' in bridge configuration.');
25
+ }
26
+ if (!['desktop', 'web'].includes(config.mode)) throw new Error('Bridge mode must be desktop or web.');
27
+ if (config.mode === 'web') webTarget(config.base);
28
+ return config;
29
+ }
30
+
31
+ /** @author ddj 2026-09-28 @param {string} value Selected base URL @returns {string} Safe, unchanged wake target. */
32
+ export function webTarget(value) {
33
+ const url = new URL(value);
34
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
35
+ throw new Error('Invalid selected Web base URL.');
36
+ }
37
+ return value;
38
+ }
39
+
40
+ /** @author ddj 2026-09-28 @param {string} path Filesystem path @returns {Promise<void>} Reject linked ancestors. */
41
+ async function noLinks(path) {
42
+ let cursor = resolve(path);
43
+ while (true) {
44
+ const info = await lstat(cursor);
45
+ if (info.isSymbolicLink()) throw new Error('Linked bridge paths are not allowed: ' + cursor);
46
+ const parent = dirname(cursor);
47
+ if (parent === cursor) break;
48
+ cursor = parent;
49
+ }
50
+ const physical = await realpath(path);
51
+ const equal = process.platform === 'win32'
52
+ ? physical.toLowerCase() === resolve(path).toLowerCase()
53
+ : physical === resolve(path);
54
+ if (!equal) throw new Error('Bridge path escapes its physical location.');
55
+ }
56
+
57
+ /** @author ddj 2026-09-28 @param {string} path Private directory or file @returns {Promise<void>} Validate Windows owner and ACL. */
58
+ async function windowsAcl(path) {
59
+ const script = "$ErrorActionPreference='Stop'; $p=$env:DSH_OPEN_CHECK; " +
60
+ "$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; " +
61
+ "if([IO.Directory]::Exists($p)) { $a=[IO.Directory]::GetAccessControl($p);$f=[IO.DirectoryInfo]::new($p) } " +
62
+ "else { $a=[IO.File]::GetAccessControl($p);$f=[IO.FileInfo]::new($p) }; " +
63
+ "if ($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { exit 4 }; " +
64
+ "$rules=$a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]); $allow=$false; " +
65
+ "foreach($r in $rules) { if($r.AccessControlType -eq 'Allow' -and $r.FileSystemRights -ne 0) { " +
66
+ "if($r.IdentityReference.Value -ne $sid.Value) { exit 5 }; $allow=$true } }; " +
67
+ "if(-not $allow) { exit 6 }; " +
68
+ "while($null -ne $f) { if(($f.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 7 }; " +
69
+ "if($f -is [IO.FileInfo]) { $f=$f.Directory } else { $f=$f.Parent } }";
70
+ const executable = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
71
+ await runProcess(executable, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], {
72
+ env: { ...process.env, DSH_OPEN_CHECK: path }, windowsHide: true,
73
+ });
74
+ }
75
+
76
+ /** @author ddj 2026-09-28 @param {string} path Queue entry @param {boolean} directory Expected kind @returns {Promise<void>} Enforce current-user-only access. */
77
+ export async function privatePath(path, directory = true) {
78
+ await noLinks(path);
79
+ const stat = await lstat(path);
80
+ if (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) throw new Error('Invalid bridge entry type.');
81
+ if (!directory && stat.size > MAX_BYTES) throw new Error('Bridge entry exceeds 64 KiB.');
82
+ if (process.platform === 'win32') return windowsAcl(path);
83
+ if (stat.uid !== process.getuid() || (stat.mode & 0o777) !== (directory ? 0o700 : 0o600)) {
84
+ throw new Error('Bridge entry is not private to the current user: ' + path);
85
+ }
86
+ }
87
+
88
+ /** @author ddj 2026-09-28 @param {object} config Bridge metadata @returns {Promise<void>} Validate existing host-owned queue; never create directories. */
89
+ async function checkQueue(config) {
90
+ await privatePath(config.inbox);
91
+ await privatePath(join(config.inbox, 'requests'));
92
+ await privatePath(join(config.inbox, 'acks'));
93
+ }
94
+
95
+ /** @author ddj 2026-09-28 @param {object} config Bridge metadata @param {string[]} paths Absolute inputs @param {object} position Optional line/column @returns {object} Validated request. */
96
+ export function makeRequest(config, paths, position = {}) {
97
+ if (!config.profile || !isAbsolute(config.profile)) throw new Error('Missing absolute profile.');
98
+ if (!paths.length || paths.length > 20) throw new Error('Open requires between 1 and 20 paths.');
99
+ if (paths.some(path => typeof path !== 'string' || !isAbsolute(path) || path.length > 8192 || /[\u0000-\u001f]/.test(path))) {
100
+ throw new Error('Open paths must be absolute and at most 8192 characters.');
101
+ }
102
+ const request = { version: 1, requestId: randomUUID(), profile: config.profile, paths, createdAt: Date.now() };
103
+ for (const key of ['line', 'column']) {
104
+ if (position[key] === undefined) continue;
105
+ if (!Number.isSafeInteger(position[key]) || position[key] <= 0) throw new Error('Invalid ' + key + '.');
106
+ request[key] = position[key];
107
+ }
108
+ if (Buffer.byteLength(JSON.stringify(request)) > MAX_BYTES) throw new Error('Open request exceeds 64 KiB.');
109
+ return request;
110
+ }
111
+
112
+ /** @author ddj 2026-09-28 @param {object} config Bridge metadata @param {object} request Validated request @returns {Promise<void>} Exclusively publish an atomic JSON file. */
113
+ async function publish(config, request) {
114
+ const folder = join(config.inbox, 'requests');
115
+ const temp = join(folder, request.requestId + '.tmp');
116
+ const target = join(folder, request.requestId + '.json');
117
+ await checkQueue(config);
118
+ let owned = false;
119
+ try {
120
+ const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
121
+ owned = true;
122
+ try { await handle.writeFile(JSON.stringify(request), 'utf8'); await handle.sync(); }
123
+ finally { await handle.close(); }
124
+ await privatePath(temp, false);
125
+ await checkQueue(config);
126
+ // UUID + exclusive temporary creation prevents competing producers sharing this destination.
127
+ try { await lstat(target); throw new Error('Request ID already exists.'); }
128
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
129
+ await rename(temp, target);
130
+ owned = false;
131
+ } finally {
132
+ if (owned) await unlink(temp).catch(() => {});
133
+ }
134
+ }
135
+
136
+ /** @author ddj 2026-09-28 @param {object} ack Untrusted receipt @param {object} request Submitted request @param {number} now Clock @returns {boolean} Matching, fresh success only. */
137
+ export function validAck(ack, request, now = Date.now()) {
138
+ if (!ack || typeof ack !== 'object' || Array.isArray(ack)) return false;
139
+ const keys = ['version', 'requestId', 'profile', 'success', 'error', 'createdAt'];
140
+ if (Object.keys(ack).some(key => !keys.includes(key)) || (ack.error !== undefined && typeof ack.error !== 'string')) return false;
141
+ return ack.version === 1 && ack.requestId === request.requestId &&
142
+ ack.profile === request.profile && typeof ack.success === 'boolean' && Number.isSafeInteger(ack.createdAt) &&
143
+ ack.createdAt >= request.createdAt && ack.createdAt <= now + 5000 && now - ack.createdAt <= TTL;
144
+ }
145
+
146
+ /** @author ddj 2026-09-28 @param {number} ms Poll delay @returns {Promise<void>} Nonblocking timer. */
147
+ function pause(ms) { return new Promise(resolveWait => setTimeout(resolveWait, ms)); }
148
+
149
+ /** @author ddj 2026-09-28 @param {object} config Bridge metadata @param {object} request Submitted request @param {number} timeout Maximum ACK wait @param {Function} cancelWake Cancel deferred activation @returns {Promise<object>} Confirmed receipt, or throw. */
150
+ async function waitAck(config, request, timeout, cancelWake) {
151
+ const file = join(config.inbox, 'acks', request.requestId + '.json');
152
+ const deadline = request.createdAt + Math.min(timeout, TTL);
153
+ while (Date.now() < deadline) {
154
+ try {
155
+ const stat = await lstat(file);
156
+ cancelWake();
157
+ if (stat.size > MAX_BYTES) throw new Error('ACK exceeds 64 KiB.');
158
+ await checkQueue(config);
159
+ await privatePath(file, false);
160
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
161
+ let ack;
162
+ try {
163
+ const current = await handle.stat();
164
+ if (!current.isFile() || current.size > MAX_BYTES || current.nlink !== 1 || current.ino !== stat.ino || current.dev !== stat.dev) {
165
+ throw new Error('Invalid or replaced ACK file.');
166
+ }
167
+ const buffer = Buffer.alloc(MAX_BYTES + 1);
168
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
169
+ if (bytesRead > MAX_BYTES) throw new Error('ACK exceeds 64 KiB.');
170
+ ack = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8'));
171
+ } finally { await handle.close(); }
172
+ if (!validAck(ack, request)) throw new Error('Invalid or mismatched OPEN ACK.');
173
+ if (!ack.success) throw new Error(typeof ack.error === 'string' ? ack.error : 'DSH rejected the open request.');
174
+ return ack;
175
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
176
+ await pause(100);
177
+ }
178
+ throw new Error('Timed out waiting for DSH OPEN ACK; opening was not confirmed.');
179
+ }
180
+
181
+ /** @author ddj 2026-09-28 @param {string} executable Program @param {string[]} args Arguments @param {object} options Spawn options @returns {Promise<void>} Require a successful process exit. */
182
+ function runProcess(executable, args, options = {}) {
183
+ return new Promise((resolveExit, reject) => {
184
+ const child = spawn(executable, args, { stdio: 'ignore', timeout: 10_000, ...options });
185
+ child.once('error', reject);
186
+ child.once('exit', code => code === 0 ? resolveExit() : reject(new Error('Bridge process failed (' + code + ').')));
187
+ });
188
+ }
189
+
190
+ /** @author ddj 2026-09-28 @param {object} config Bridge metadata @returns {Promise<void>} Wake only the official desktop URI or selected Web base. */
191
+ async function wake(config) {
192
+ const target = config.mode === 'desktop' ? 'dsh://open' : webTarget(config.base);
193
+ if (process.platform === 'win32') {
194
+ const executable = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
195
+ const script = "$ErrorActionPreference='Stop';$s=[Diagnostics.ProcessStartInfo]::new($env:DSH_OPEN_WAKE);" +
196
+ "$s.UseShellExecute=$true;$s.WindowStyle=[Diagnostics.ProcessWindowStyle]::Normal;[Diagnostics.Process]::Start($s)|Out-Null";
197
+ await runProcess(executable, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], {
198
+ env: { ...process.env, DSH_OPEN_WAKE: target }, windowsHide: true,
199
+ });
200
+ } else {
201
+ await runProcess(process.platform === 'darwin' ? '/usr/bin/open' : 'xdg-open', [target]);
202
+ }
203
+ }
204
+
205
+ /** @author ddj 2026-09-28 @param {object} config Bridge metadata @param {string[]} paths Absolute inputs @param {object} options Position, timeout, activation callback @returns {Promise<object>} Confirmed ACK. */
206
+ export async function sendOpen(config, paths, options = {}) {
207
+ const request = makeRequest(config, paths, options);
208
+ await publish(config, request);
209
+ // Give an already running client a chance to finish without opening another Web tab.
210
+ const activate = options.activate || wake;
211
+ const timer = setTimeout(() => { Promise.resolve(activate(config)).catch(() => {}); }, 700);
212
+ try { return await waitAck(config, request, options.timeout ?? TTL, () => clearTimeout(timer)); }
213
+ finally { clearTimeout(timer); }
214
+ }
215
+
216
+ /** @author ddj 2026-09-28 @param {string[]} args CLI arguments @returns {Promise<void>} Run producer and expose errors through exit status. */
217
+ async function main(args) {
218
+ let configFile = join(dirname(fileURLToPath(import.meta.url)), 'dsh-open.ini');
219
+ const options = {};
220
+ let unity = false;
221
+ let index = 0;
222
+ for (; index < args.length; index++) {
223
+ const arg = args[index];
224
+ if (arg === '--') { index++; break; }
225
+ if (arg === '--config') configFile = args[++index];
226
+ else if (arg === '--line' || arg === '--column') options[arg.slice(2)] = Number(args[++index]);
227
+ else if (arg === '--no-wake') unity = true;
228
+ else throw new Error('Unknown launcher option: ' + arg);
229
+ }
230
+ const paths = args.slice(index).map(path => resolve(path));
231
+ if (!paths.length) return;
232
+ const config = await readConfig(configFile);
233
+ if (unity) options.activate = async value => {
234
+ process.stdout.write('DSH_WAKE:' + (value.mode === 'desktop' ? 'dsh://open' : webTarget(value.base)) + '\n');
235
+ };
236
+ await sendOpen(config, paths, options);
237
+ }
238
+
239
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
240
+ main(process.argv.slice(2)).catch(error => { console.error('DSH OPEN: ' + error.message); process.exitCode = 1; });
241
+ }
@@ -1,52 +1,33 @@
1
- # dsh-open.ps1 — DSH 文件编辑 shell launcher(csc 不可用时的降级版,轻微控制台闪窗)。
2
- # 行为与 dsh-open.cs 一致:读 dsh-open.ini base → 探测端口 → 投递待打开请求(有已打开
3
- # DSH 页面时由其就地执行,2s 未领取取回并回退开新页)。
4
- # 由 dsh-vscode-mode 设置页一键注册写入 HKCU\...\shell\DSHEditor\command。
5
- # 作者 ddj 2026-09-08
6
- param([string[]]$Path)
1
+ # Windows fallback launcher. The shared helper validates the private queue and waits for ACK.
2
+ # @author ddj 2026-09-28
3
+ param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Path)
7
4
  $ErrorActionPreference = 'Stop'
8
- $base = 'http://127.0.0.1:3080'
9
- $ini = Join-Path $PSScriptRoot 'dsh-open.ini'
10
- if (Test-Path $ini) {
11
- foreach ($line in Get-Content $ini) {
12
- if ($line -match '^\s*base\s*=\s*(.+?)\s*$') { $base = $Matches[1]; break }
13
- }
14
- }
15
-
16
- $alive = $false
5
+ if (-not $Path -or $Path.Count -eq 0) { exit 0 }
17
6
  try {
18
- $uri = [Uri]$base
19
- $port = if ($uri.IsDefaultPort) { if ($uri.Scheme -ieq 'https') { 443 } else { 80 } } else { $uri.Port }
20
- $client = New-Object Net.Sockets.TcpClient
21
- $alive = $client.ConnectAsync($uri.Host, $port).Wait(1000) -and $client.Connected
22
- $client.Close()
23
- } catch { $alive = $false }
24
-
25
- if (-not $alive) {
7
+ $ini = Join-Path $PSScriptRoot 'dsh-open.ini'
8
+ $config = @{}
9
+ foreach ($line in [IO.File]::ReadAllLines($ini)) {
10
+ $equal = $line.IndexOf('=')
11
+ if ($equal -gt 0) { $config[$line.Substring(0, $equal).Trim()] = $line.Substring($equal + 1).Trim() }
12
+ }
13
+ foreach ($key in @('node', 'helper')) {
14
+ $value = $config[$key]
15
+ if (-not $value -or -not [IO.Path]::IsPathRooted($value) -or
16
+ [IO.Path]::GetFullPath($value) -ine $value -or -not [IO.File]::Exists($value)) {
17
+ throw "Missing absolute $key in DSH bridge configuration."
18
+ }
19
+ }
20
+ if ([IO.Path]::GetExtension($config.node) -ine '.exe') { throw 'A stored Node or Electron executable is required.' }
21
+ if ($config.nodeMode -notin @('node', 'electron')) { throw 'Missing nodeMode in DSH bridge configuration.' }
22
+ if ($config.nodeMode -eq 'electron') { $env:ELECTRON_RUN_AS_NODE = '1' }
23
+ else { Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue }
24
+ $absolute = @($Path | ForEach-Object { [IO.Path]::GetFullPath($_) })
25
+ & $config.node $config.helper --config $ini -- @absolute
26
+ if ($LASTEXITCODE -ne 0) { throw 'DSH OPEN was not confirmed. See the producer error above.' }
27
+ exit 0
28
+ } catch {
29
+ [Console]::Error.WriteLine($_.Exception.Message)
26
30
  Add-Type -AssemblyName System.Windows.Forms
27
- [System.Windows.Forms.MessageBox]::Show(
28
- "DSH Web UI 未运行($base)。`n请先启动 DSH,再使用「在 DSH 文件编辑中打开」。",
29
- 'DSH 文件编辑') | Out-Null
31
+ [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'DSH 文件编辑') | Out-Null
30
32
  exit 1
31
33
  }
32
-
33
- $paths = @($Path | Where-Object { $_ } | ForEach-Object { [IO.Path]::GetFullPath($_) })
34
- if (-not $paths -or $paths.Count -eq 0) { exit 0 }
35
-
36
- # 复用已有页面:投递待打开请求,clients>0 表示有页面在(2s 后确认领取,未领取取回回退)
37
- $handoff = $null
38
- try {
39
- $rpcBase = $base.TrimEnd('/') + '/edrv/rpc'
40
- $body = @{ method = 'edrv.external.handoff'; args = @{ paths = $paths } } | ConvertTo-Json -Depth 4 -Compress
41
- $handoff = Invoke-RestMethod -Uri $rpcBase -Method Post -ContentType 'application/json' -Body $body -TimeoutSec 4
42
- if ($handoff.clients -gt 0) {
43
- Start-Sleep -Milliseconds 2000
44
- $stateBody = @{ method = 'edrv.external.pendingState'; args = @{ token = $handoff.token; take = $true } } | ConvertTo-Json -Depth 4 -Compress
45
- $state = Invoke-RestMethod -Uri $rpcBase -Method Post -ContentType 'application/json' -Body $stateBody -TimeoutSec 4
46
- if ($state.delivered) { exit 0 }
47
- }
48
- } catch { # 移交失败回退开新页
49
- }
50
-
51
- $encoded = @($paths | ForEach-Object { [Uri]::EscapeDataString($_) }) -join ','
52
- Start-Process ($base.TrimEnd('/') + '/?edrvOpen=1&edrvPaths=' + $encoded)
@@ -1,70 +1,29 @@
1
1
  #!/usr/bin/env bash
2
- # dsh-open.sh — DSH 文件编辑 shell launcher(Linux/macOS 通用,bash 3.2+)。
3
- # 由 dsh-vscode-mode 设置页注册写入:Nautilus 脚本 / Dolphin 服务菜单 / macOS 快速操作引用本脚本。
4
- # 行为:读同目录 dsh-open.ini 的 base 地址 → curl 探测(可用时,1s 超时)→ 深链打开默认浏览器。
5
- # 深链契约见插件 src/shared/externalOpen.ts(edrvOpen/edrvPaths/edrvLine/edrvColumn)。
6
- # 作者 ddj 2026-09-07
7
- set -u
8
- export LC_ALL=C
9
-
2
+ # POSIX launcher; use the interpreter recorded by the selected DSH host.
3
+ # @author ddj 2026-09-28
4
+ set -eu
5
+ [ "$#" -gt 0 ] || exit 0
10
6
  case "$0" in */*) ini_dir="${0%/*}" ;; *) ini_dir="." ;; esac
11
- base="http://127.0.0.1:3080"
7
+ ini_dir=$(cd "$ini_dir" && pwd -P)
12
8
  ini="$ini_dir/dsh-open.ini"
13
- if [ -f "$ini" ]; then
14
- parsed=$(sed -n 's/^[[:space:]]*[Bb]ase[[:space:]]*=[[:space:]]*//p' "$ini" | head -n 1)
15
- [ -n "$parsed" ] && base="$parsed"
16
- fi
17
-
18
- # 逐字节 percent-encode:LC_ALL=C 下 ${s:i:1} 按字节迭代,多字节字符(中文等)安全;
19
- # unreserved 保留,其余全部 %XX(编码结果不含裸逗号/空白,逗号分隔契约安全)。
20
- urlencode() {
21
- local s="$1" out="" i c hex
22
- for ((i = 0; i < ${#s}; i++)); do
23
- c="${s:i:1}"
24
- case "$c" in
25
- [A-Za-z0-9.~_-]) out+="$c" ;;
26
- *) printf -v hex '%%%02X' "'$c" ; out+="$hex" ;;
27
- esac
28
- done
29
- printf '%s' "$out"
30
- }
31
-
32
- alert() {
33
- local msg="$1"
34
- if [ "$(uname)" = "Darwin" ]; then
35
- osascript -e "display dialog \"$msg\" with title \"DSH 文件编辑\"" >/dev/null 2>&1 || true
36
- elif command -v notify-send >/dev/null 2>&1; then
37
- notify-send "DSH 文件编辑" "$msg" || true
38
- elif command -v zenity >/dev/null 2>&1; then
39
- zenity --info --title "DSH 文件编辑" --text "$msg" >/dev/null 2>&1 || true
40
- fi
41
- }
42
-
43
- # 无参数(如 Nautilus 空选)静默退出
44
- [ $# -gt 0 ] || exit 0
45
-
46
- # DSH 未运行提示(curl 缺失时跳过探测直接打开)
47
- if command -v curl >/dev/null 2>&1; then
48
- if ! curl -s -o /dev/null --max-time 1 "$base"; then
49
- alert "DSH Web UI 未运行($base)。请先启动 DSH。"
50
- exit 1
51
- fi
52
- fi
53
-
54
- # 全部路径编码后合并为一个深链(多选一次打开)
55
- query=""
56
- for p in "$@"; do
57
- [ -n "$p" ] || continue
58
- abs=$(cd "$(dirname "$p")" 2>/dev/null && pwd)/$(basename "$p")
59
- [ -e "$abs" ] || abs="$p"
60
- seg=$(urlencode "$abs")
61
- [ -n "$seg" ] || continue
62
- if [ -z "$query" ]; then query="$seg"; else query="$query,$seg"; fi
63
- done
64
- [ -n "$query" ] || exit 0
65
-
66
- url="${base%/}/?edrvOpen=1&edrvPaths=$query"
67
- case "$(uname)" in
68
- Darwin) exec open "$url" ;;
69
- *) exec xdg-open "$url" ;;
9
+ node=""
10
+ node_mode=""
11
+ helper=""
12
+ # Read values as data; never source/eval an INI file.
13
+ while IFS='=' read -r key value || [ -n "$key" ]; do
14
+ value=${value%$'\r'}
15
+ case "$key" in
16
+ node) node="$value" ;;
17
+ nodeMode) node_mode="$value" ;;
18
+ helper) helper="$value" ;;
19
+ esac
20
+ done < "$ini"
21
+ case "$node" in /*) ;; *) printf '%s\n' 'DSH: missing absolute node executable.' >&2; exit 1 ;; esac
22
+ case "$helper" in /*) ;; *) printf '%s\n' 'DSH: missing absolute producer helper.' >&2; exit 1 ;; esac
23
+ [ -x "$node" ] && [ -f "$helper" ] || { printf '%s\n' 'DSH: configured producer is unavailable.' >&2; exit 1; }
24
+ case "$node_mode" in
25
+ electron) export ELECTRON_RUN_AS_NODE=1 ;;
26
+ node) unset ELECTRON_RUN_AS_NODE ;;
27
+ *) printf '%s\n' 'DSH: missing nodeMode.' >&2; exit 1 ;;
70
28
  esac
29
+ exec "$node" "$helper" --config "$ini" -- "$@"