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.
- package/README.md +148 -112
- package/assets/shell/dsh-open.cs +65 -149
- package/assets/shell/dsh-open.mjs +241 -0
- package/assets/shell/dsh-open.ps1 +28 -47
- package/assets/shell/dsh-open.sh +25 -66
- package/lib/client.js +30185 -29711
- package/lib/client.js.map +1 -1
- package/lib/index.js +3889 -2336
- package/lib/index.js.map +1 -1
- package/package.json +9 -8
- package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
- package/src/childEnv.ts +19 -0
- package/src/client/addToConversation.ts +4 -2
- package/src/client/ai/inlineProvider.ts +1 -8
- package/src/client/commandBridge.ts +6 -2
- package/src/client/externalOpen.ts +40 -19
- package/src/client/index.ts +17 -15
- package/src/client/keybindings.ts +18 -1
- package/src/client/monaco/loader.ts +3 -1
- package/src/client/openFlow.ts +36 -28
- package/src/client/openReceipt.ts +64 -0
- package/src/client/panelCommands.ts +101 -0
- package/src/client/pdf/pdfLoader.ts +4 -2
- package/src/client/receiptState.ts +120 -0
- package/src/client/rpc.ts +30 -4
- package/src/client/shortcutsOfficial.ts +39 -4
- package/src/client/sidebarBridge.ts +7 -0
- package/src/client/styles/mcp.css +1 -19
- package/src/client/ui/DiffBadge.ts +2 -1
- package/src/client/ui/EditorView.ts +237 -55
- package/src/client/ui/McpSettings.ts +32 -7
- package/src/client/ui/OfficialSideTab.ts +5 -2
- package/src/client/ui/QuickOpen.ts +10 -9
- package/src/client/ui/commandCatalog.ts +5 -4
- package/src/client/useOpenReceipt.ts +128 -0
- package/src/compat.ts +5 -3
- package/src/dap/manager.ts +46 -13
- package/src/devForm.ts +252 -92
- package/src/externalHandoff.ts +93 -52
- package/src/fileOpenSettings.ts +1 -24
- package/src/hostImport.ts +19 -0
- package/src/httpGuard.ts +101 -0
- package/src/index.ts +106 -97
- package/src/integrate.ts +56 -50
- package/src/lsp/transport.ts +84 -127
- package/src/mcp.ts +58 -9
- package/src/mcpIsolation.ts +211 -102
- package/src/mcpProject.ts +220 -133
- package/src/mcpRuntime.ts +343 -0
- package/src/openBridge.ts +69 -0
- package/src/openInbox.ts +207 -0
- package/src/paths.ts +8 -5
- package/src/processTree.ts +63 -0
- package/src/reveal.ts +5 -3
- package/src/routes.ts +156 -154
- package/src/rpc.ts +14 -13
- package/src/shared/appUrl.ts +19 -0
- package/src/shared/externalOpen.ts +71 -0
- package/src/shared/integration.ts +2 -0
- package/src/shared/keybindings.ts +8 -5
- package/src/shared/mcp.ts +4 -1
- package/src/shared/rpc.ts +7 -4
- package/src/shared/svn.ts +6 -8
- package/src/svnPlanTool.ts +51 -0
- package/src/unityBridge.ts +26 -3
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
- package/unity/com.dsh.editor/README.md +16 -25
- package/unity/com.dsh.editor/package.json +2 -2
- 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
|
-
#
|
|
2
|
-
#
|
|
3
|
-
|
|
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
|
-
$
|
|
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
|
-
$
|
|
19
|
-
$
|
|
20
|
-
$
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
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)
|
package/assets/shell/dsh-open.sh
CHANGED
|
@@ -1,70 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
ini_dir=$(cd "$ini_dir" && pwd -P)
|
|
12
8
|
ini="$ini_dir/dsh-open.ini"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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" -- "$@"
|