dsh-vscode-mode 0.11.0 → 0.13.0
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 +158 -111
- 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 +13362 -11806
- package/lib/client.js.map +1 -1
- package/lib/index.js +4110 -2507
- 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/activePathStore.ts +28 -0
- package/src/client/addToConversation.ts +4 -2
- package/src/client/ai/inlineProvider.ts +1 -8
- package/src/client/commandBridge.ts +14 -42
- package/src/client/externalOpen.ts +40 -19
- package/src/client/index.ts +101 -18
- package/src/client/keybindings.ts +36 -73
- 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/shortcutRecorder.ts +161 -0
- package/src/client/shortcutsOfficial.ts +460 -0
- package/src/client/sidebar/panels/RulesPanel.ts +15 -11
- package/src/client/sidebarBridge.ts +7 -0
- package/src/client/ui/DiffBadge.ts +2 -1
- package/src/client/ui/EditorView.ts +246 -137
- package/src/client/ui/McpSettings.ts +10 -8
- package/src/client/ui/OfficialSideTab.ts +5 -2
- package/src/client/ui/QuickOpen.ts +16 -16
- package/src/client/ui/ShortcutSettings.ts +274 -0
- package/src/client/ui/commandCatalog.ts +39 -43
- 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 +4 -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/rules.ts +100 -44
- 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 +256 -0
- package/src/shared/mcp.ts +4 -1
- package/src/shared/rpc.ts +7 -4
- package/src/shared/rules.ts +9 -1
- 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/KeybindingsPanel.ts +0 -190
package/assets/shell/dsh-open.cs
CHANGED
|
@@ -1,182 +1,98 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// 流程:读 dsh-open.ini base → TcpClient 探测(1s 超时,未运行弹 MessageBox)
|
|
4
|
-
// → 投递待打开请求到 host(edrv.external.handoff):有已打开 DSH 页面时由其就地
|
|
5
|
-
// 执行打开规则(不重复开页);2s 未领取(页面刚关/后台节流)则取回请求并回退打开新页。
|
|
6
|
-
// 深链契约见插件 src/shared/externalOpen.ts(edrvOpen/edrvPaths/edrvLine/edrvColumn)。
|
|
7
|
-
// 作者 ddj 2026-09-08
|
|
1
|
+
// Windows C# 5 launcher; shared producer owns private queue validation and ACK handling.
|
|
2
|
+
// @author ddj 2026-09-28
|
|
8
3
|
using System;
|
|
9
4
|
using System.Collections.Generic;
|
|
10
5
|
using System.Diagnostics;
|
|
11
6
|
using System.IO;
|
|
12
|
-
using System.Net;
|
|
13
|
-
using System.Net.Sockets;
|
|
14
7
|
using System.Text;
|
|
15
|
-
using System.Threading;
|
|
16
|
-
using System.Web.Script.Serialization;
|
|
17
8
|
using System.Windows.Forms;
|
|
18
9
|
|
|
19
10
|
internal static class DshOpen
|
|
20
11
|
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
/// <summary>Run the configured producer and report unconfirmed opens. @author ddj 2026-09-28</summary>
|
|
13
|
+
/// <param name="args">Paths supplied by Explorer.</param>
|
|
14
|
+
/// <returns>Zero only when the producer confirms an ACK (or no paths were supplied).</returns>
|
|
24
15
|
[STAThread]
|
|
25
|
-
private static
|
|
16
|
+
private static int Main(string[] args)
|
|
26
17
|
{
|
|
27
|
-
|
|
28
|
-
|
|
18
|
+
if (args.Length == 0) return 0;
|
|
19
|
+
try { return Run(args); }
|
|
20
|
+
catch (Exception error)
|
|
29
21
|
{
|
|
30
|
-
MessageBox.Show(
|
|
31
|
-
|
|
32
|
-
"DSH 文件编辑",
|
|
33
|
-
MessageBoxButtons.OK,
|
|
34
|
-
MessageBoxIcon.Information);
|
|
35
|
-
return;
|
|
22
|
+
MessageBox.Show(error.Message, "DSH 文件编辑", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
|
23
|
+
return 1;
|
|
36
24
|
}
|
|
37
|
-
List<string> paths = CollectPaths(args);
|
|
38
|
-
if (paths.Count == 0) return;
|
|
39
|
-
if (HandoffAndConfirm(baseUrl, paths)) return;
|
|
40
|
-
OpenUrl(BuildUrl(baseUrl, paths));
|
|
41
25
|
}
|
|
42
26
|
|
|
43
|
-
/// <summary
|
|
44
|
-
|
|
27
|
+
/// <summary>Read installed metadata and wait for the queue producer. @author ddj 2026-09-28</summary>
|
|
28
|
+
/// <param name="paths">Paths to open.</param><returns>Confirmed producer exit status.</returns>
|
|
29
|
+
private static int Run(string[] paths)
|
|
45
30
|
{
|
|
46
|
-
|
|
47
|
-
|
|
31
|
+
string folder = AppDomain.CurrentDomain.BaseDirectory;
|
|
32
|
+
string ini = Path.Combine(folder, "dsh-open.ini");
|
|
33
|
+
Dictionary<string, string> config = ReadIni(ini);
|
|
34
|
+
string executable = RequiredPath(config, "node");
|
|
35
|
+
string helper = RequiredPath(config, "helper");
|
|
36
|
+
string mode;
|
|
37
|
+
if (!config.TryGetValue("nodeMode", out mode) || (mode != "node" && mode != "electron"))
|
|
38
|
+
throw new InvalidDataException("Missing nodeMode in DSH bridge configuration.");
|
|
39
|
+
var arguments = new StringBuilder(Quote(helper) + " --config " + Quote(ini) + " --");
|
|
40
|
+
foreach (string path in paths) arguments.Append(" " + Quote(Path.GetFullPath(path)));
|
|
41
|
+
var start = new ProcessStartInfo(executable, arguments.ToString());
|
|
42
|
+
start.UseShellExecute = false;
|
|
43
|
+
start.CreateNoWindow = true;
|
|
44
|
+
start.RedirectStandardError = true;
|
|
45
|
+
if (mode == "electron") start.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1";
|
|
46
|
+
else start.EnvironmentVariables.Remove("ELECTRON_RUN_AS_NODE");
|
|
47
|
+
using (Process child = Process.Start(start))
|
|
48
48
|
{
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!string.IsNullOrEmpty(path)) paths.Add(path);
|
|
49
|
+
string error = child.StandardError.ReadToEnd();
|
|
50
|
+
child.WaitForExit();
|
|
51
|
+
if (child.ExitCode != 0) throw new IOException(string.IsNullOrWhiteSpace(error) ? "DSH OPEN was not confirmed." : error);
|
|
52
|
+
return 0;
|
|
54
53
|
}
|
|
55
|
-
return paths;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/// <summary>投递待打开请求;返回 true = 已确认由既有 DSH 页面执行(无需开新页)。</summary>
|
|
59
|
-
private static bool HandoffAndConfirm(string baseUrl, List<string> paths)
|
|
60
|
-
{
|
|
61
|
-
string resp = PostRpc(baseUrl, "edrv.external.handoff", "{\"paths\":" + JsonArrayOf(paths) + "}");
|
|
62
|
-
var data = ParseJson(resp);
|
|
63
|
-
if (data == null || ToInt(data, "clients") <= 0) return false;
|
|
64
|
-
Thread.Sleep(2000);
|
|
65
|
-
string token = data.ContainsKey("token") ? data["token"] as string : null;
|
|
66
|
-
string state = PostRpc(baseUrl, "edrv.external.pendingState",
|
|
67
|
-
"{\"token\":" + JsonString(token) + ",\"take\":true}");
|
|
68
|
-
var st = ParseJson(state);
|
|
69
|
-
return st != null && ToBool(st, "delivered");
|
|
70
54
|
}
|
|
71
55
|
|
|
72
|
-
|
|
56
|
+
/// <summary>Read an existing INI; missing metadata fails closed. @author ddj 2026-09-28</summary>
|
|
57
|
+
/// <param name="file">Installed config path.</param><returns>Case-insensitive values.</returns>
|
|
58
|
+
private static Dictionary<string, string> ReadIni(string file)
|
|
73
59
|
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
/// <summary>POST /edrv/rpc;失败返回 null(回退开新页,不阻塞)。</summary>
|
|
78
|
-
private static string PostRpc(string baseUrl, string method, string argsJson)
|
|
79
|
-
{
|
|
80
|
-
try
|
|
60
|
+
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
61
|
+
foreach (string line in File.ReadAllLines(file, Encoding.UTF8))
|
|
81
62
|
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
req.ContentType = "application/json";
|
|
85
|
-
req.Timeout = 4000;
|
|
86
|
-
byte[] body = Encoding.UTF8.GetBytes("{\"method\":\"" + method + "\",\"args\":" + argsJson + "}");
|
|
87
|
-
req.ContentLength = body.Length;
|
|
88
|
-
using (Stream stream = req.GetRequestStream()) stream.Write(body, 0, body.Length);
|
|
89
|
-
using (WebResponse res = req.GetResponse())
|
|
90
|
-
using (StreamReader reader = new StreamReader(res.GetResponseStream(), Encoding.UTF8))
|
|
91
|
-
return reader.ReadToEnd();
|
|
63
|
+
int equal = line.IndexOf('=');
|
|
64
|
+
if (equal > 0) values[line.Substring(0, equal).Trim()] = line.Substring(equal + 1).Trim();
|
|
92
65
|
}
|
|
93
|
-
|
|
66
|
+
return values;
|
|
94
67
|
}
|
|
95
68
|
|
|
96
|
-
/// <summary
|
|
97
|
-
|
|
69
|
+
/// <summary>Require a fully qualified existing file. @author ddj 2026-09-28</summary>
|
|
70
|
+
/// <param name="config">INI values.</param><param name="key">Metadata key.</param><returns>Validated path.</returns>
|
|
71
|
+
private static string RequiredPath(Dictionary<string, string> config, string key)
|
|
98
72
|
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
object value;
|
|
107
|
-
return data.TryGetValue(key, out value) ? Convert.ToInt32(value) : 0;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private static bool ToBool(Dictionary<string, object> data, string key)
|
|
111
|
-
{
|
|
112
|
-
object value;
|
|
113
|
-
return data.TryGetValue(key, out value) && Convert.ToBoolean(value);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private static string JsonString(string value)
|
|
117
|
-
{
|
|
118
|
-
return "\"" + (value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
private static string JsonArrayOf(List<string> items)
|
|
122
|
-
{
|
|
123
|
-
var parts = new List<string>();
|
|
124
|
-
foreach (string item in items) parts.Add(JsonString(item));
|
|
125
|
-
return "[" + string.Join(",", parts.ToArray()) + "]";
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/// <summary>读取同目录 dsh-open.ini 的 base= 行;缺失或非法回退默认地址。</summary>
|
|
129
|
-
private static string ReadBase()
|
|
130
|
-
{
|
|
131
|
-
try
|
|
132
|
-
{
|
|
133
|
-
string ini = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "dsh-open.ini");
|
|
134
|
-
if (!File.Exists(ini)) return DefaultBase;
|
|
135
|
-
foreach (string line in File.ReadAllLines(ini, Encoding.UTF8))
|
|
136
|
-
{
|
|
137
|
-
string trimmed = line.Trim();
|
|
138
|
-
if (trimmed.StartsWith("base=", StringComparison.OrdinalIgnoreCase) && trimmed.Length > 5)
|
|
139
|
-
{
|
|
140
|
-
string value = trimmed.Substring(5).Trim();
|
|
141
|
-
if (value.Length > 0) return value;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
catch (Exception) { /* ini 读取失败按默认地址 */ }
|
|
146
|
-
return DefaultBase;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/// <summary>TcpClient 探测 base 地址对应端口是否可达(1s 超时)。</summary>
|
|
150
|
-
private static bool ServerAlive(string baseUrl)
|
|
151
|
-
{
|
|
152
|
-
try
|
|
153
|
-
{
|
|
154
|
-
Uri uri = new Uri(baseUrl);
|
|
155
|
-
int port = uri.IsDefaultPort
|
|
156
|
-
? (string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) ? 443 : 80)
|
|
157
|
-
: uri.Port;
|
|
158
|
-
using (TcpClient client = new TcpClient())
|
|
159
|
-
{
|
|
160
|
-
if (!client.ConnectAsync(uri.Host, port).Wait(1000)) return false;
|
|
161
|
-
return client.Connected;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
catch (Exception) { return false; }
|
|
73
|
+
string value;
|
|
74
|
+
if (!config.TryGetValue(key, out value) || !Path.IsPathRooted(value) ||
|
|
75
|
+
!string.Equals(Path.GetFullPath(value), value, StringComparison.OrdinalIgnoreCase) || !File.Exists(value))
|
|
76
|
+
throw new InvalidDataException("Missing absolute " + key + " in DSH bridge configuration.");
|
|
77
|
+
if (key == "node" && !string.Equals(Path.GetExtension(value), ".exe", StringComparison.OrdinalIgnoreCase))
|
|
78
|
+
throw new InvalidDataException("DSH requires a stored Node or Electron executable, not a command shim.");
|
|
79
|
+
return value;
|
|
165
80
|
}
|
|
166
81
|
|
|
167
|
-
/// <summary
|
|
168
|
-
|
|
82
|
+
/// <summary>Quote Windows argv with backslash-before-quote handling. @author ddj 2026-09-28</summary>
|
|
83
|
+
/// <param name="value">Literal argument.</param><returns>One command-line argument.</returns>
|
|
84
|
+
private static string Quote(string value)
|
|
169
85
|
{
|
|
170
|
-
|
|
171
|
-
int
|
|
172
|
-
foreach (
|
|
86
|
+
var result = new StringBuilder("\"");
|
|
87
|
+
int slashes = 0;
|
|
88
|
+
foreach (char ch in value)
|
|
173
89
|
{
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
count++;
|
|
90
|
+
if (ch == '\\') { slashes++; continue; }
|
|
91
|
+
result.Append('\\', ch == '"' ? slashes * 2 + 1 : slashes);
|
|
92
|
+
result.Append(ch);
|
|
93
|
+
slashes = 0;
|
|
179
94
|
}
|
|
180
|
-
|
|
95
|
+
result.Append('\\', slashes * 2);
|
|
96
|
+
return result.Append('"').ToString();
|
|
181
97
|
}
|
|
182
98
|
}
|
|
@@ -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)
|