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,63 @@
1
+ /** LSP/DAP 共用进程树终止;POSIX 子进程必须 detached 成为独立进程组。 */
2
+ import { spawn, spawnSync } from 'node:child_process'
3
+ import type { ChildProcess } from 'node:child_process'
4
+ import { childEnv } from './childEnv.js'
5
+
6
+ type Logger = (line: string) => void
7
+
8
+ /**
9
+ * 按平台回收进程树;Windows 在父进程仍存活时直接 taskkill /T,避免先杀父丢失后代。
10
+ * @public
11
+ * @author ddj 2026年09月28号
12
+ * @param child 已启动的独立进程组根
13
+ * @param logger 可选错误记录器
14
+ * @param sync 宿主 exit 事件中必须同步完成
15
+ * @param platform 当前系统,测试可注入
16
+ */
17
+ export function killTree(child: ChildProcess, logger?: Logger, sync = false, platform = process.platform): void {
18
+ if (!child.pid) return
19
+ try {
20
+ if (platform !== 'win32') {
21
+ process.kill(-child.pid, 'SIGKILL')
22
+ return
23
+ }
24
+ const args = ['/PID', String(child.pid), '/T', '/F']
25
+ const options = { windowsHide: true, stdio: 'ignore' as const, env: childEnv() }
26
+ if (sync) {
27
+ const result = spawnSync('taskkill', args, { ...options, timeout: 5000 })
28
+ if (result.error) logger?.('taskkill error: ' + String(result.error))
29
+ return
30
+ }
31
+ const killer = spawn('taskkill', args, options)
32
+ killer.once('error', (error) => logger?.('taskkill error: ' + String(error)))
33
+ killer.unref()
34
+ } catch (error) {
35
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') logger?.('kill tree error: ' + String(error))
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 停止独立进程树;POSIX 先通知整组,定时升级不依赖根进程 alive 状态。
41
+ * @public
42
+ * @author ddj 2026年09月28号
43
+ * @param child 进程树根
44
+ * @param logger 可选诊断输出
45
+ * @param platform 当前系统,测试可注入
46
+ * @returns 可立即强杀的收尾函数(幂等)
47
+ */
48
+ export function stopTree(child: ChildProcess, logger?: Logger, platform = process.platform): () => void {
49
+ let finished = false
50
+ let timer: NodeJS.Timeout | undefined
51
+ /** @private @author ddj 2026年09月28号 @description 强杀树并清理升级定时器。 */
52
+ const finish = (): void => {
53
+ if (finished) return
54
+ finished = true
55
+ if (timer) clearTimeout(timer)
56
+ killTree(child, logger, false, platform)
57
+ }
58
+ if (platform === 'win32') { finish(); return finish }
59
+ try { if (child.pid) process.kill(-child.pid, 'SIGTERM') } catch { /* 根可能已经退出,仍保留升级 */ }
60
+ timer = setTimeout(finish, 1500)
61
+ timer.unref()
62
+ return finish
63
+ }
package/src/reveal.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { spawn } from 'node:child_process'
13
13
  import { dirname } from 'node:path'
14
+ import { childEnv } from './childEnv.js'
14
15
 
15
16
  /** reveal 结果(沿 revert.ts 的 Result 风格)。 */
16
17
  export type RevealResult = { ok: true } | { ok: false; error: string }
@@ -42,12 +43,13 @@ export function revealCommand(absPath: string, isDir: boolean, platform: NodeJS.
42
43
  * 构造 opener 子进程启动选项。
43
44
  * `windowsHide` 必须为 **false**:置 true 会把 Explorer 等 GUI 窗口一起隐藏,
44
45
  * 症状即「RPC 回 ok:true 但窗口不出现」(本文件头注释记录的回归)。
45
- * @author ddj 2026年09月20号
46
+ * @public
47
+ * @author ddj 2026年09月28号
46
48
  * @param cwd 子进程工作目录(打开文件时用其父目录)
47
49
  * @returns child_process.spawn 选项
48
50
  */
49
- export function revealSpawnOpts(cwd: string): { cwd: string; stdio: 'ignore'; windowsHide: false; detached: true } {
50
- return { cwd, stdio: 'ignore', windowsHide: false, detached: true }
51
+ export function revealSpawnOpts(cwd: string): { cwd: string; stdio: 'ignore'; windowsHide: false; detached: true; env: NodeJS.ProcessEnv } {
52
+ return { cwd, stdio: 'ignore', windowsHide: false, detached: true, env: childEnv() }
51
53
  }
52
54
 
53
55
  /**
package/src/routes.ts CHANGED
@@ -1,183 +1,185 @@
1
- /**
2
- * dsh-vscode-mode host — webServer 路由注册(/edrv/rpc、/edrv/assets/*、/edrv/vendor/*)。
3
- * 迁移自原 src/index.ts 的路由部分,路径/行为一字不改。
4
- * 作者 ddj 2026-08-20
5
- */
6
- import { readFile, stat } from 'node:fs/promises'
7
- import { extname, join, normalize, resolve, sep } from 'node:path'
1
+ /** Authenticated plugin HTTP routes and offline editor assets. @author ddj 2026年09月28号 */
2
+ import { readFile, realpath, stat } from 'node:fs/promises'
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+ import { extname, isAbsolute, join, relative, resolve } from 'node:path'
8
5
  import { ROUTE_PREFIX, noteOwnRoute, resetOwnRoutes, routeConflict } from './compat.js'
9
6
  import { RPC_PATH } from './shared/rpc.js'
10
7
  import type { RpcMethod, RpcRequestMap } from './shared/rpc.js'
11
8
  import type { Ctx } from './store.js'
12
9
  import { VENDOR_PREFIX, imageDirOf, vendorDirOf } from './paths.js'
10
+ import { admitRoute, readRpc, replyJson } from './httpGuard.js'
13
11
 
14
12
  const IMG_ROUTES = [
15
13
  { url: '/edrv/assets/compare-idle.png', file: 'compare_idle.png' },
16
14
  { url: '/edrv/assets/compare-select.png', file: 'compare_select.png' },
17
15
  ]
18
-
19
- /** Monaco AMD 构建静态资源(随包发布,离线可用;前缀路由 /edrv/vendor/*)。 */
20
16
  const VENDOR_MIME: Record<string, string> = {
21
- '.js': 'text/javascript; charset=utf-8',
22
- '.mjs': 'text/javascript; charset=utf-8',
23
- '.css': 'text/css; charset=utf-8',
24
- '.json': 'application/json',
25
- '.map': 'application/json',
26
- '.ts': 'text/plain; charset=utf-8',
27
- '.svg': 'image/svg+xml',
28
- '.png': 'image/png',
29
- '.gif': 'image/gif',
30
- '.ico': 'image/x-icon',
31
- '.woff': 'font/woff',
32
- '.woff2': 'font/woff2',
33
- '.ttf': 'font/ttf',
34
- '.html': 'text/html; charset=utf-8',
35
- '.md': 'text/markdown',
36
- '.bcmap': 'application/octet-stream',
37
- '.pfb': 'application/octet-stream',
17
+ '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
18
+ '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.map': 'application/json',
19
+ '.ts': 'text/plain; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png',
20
+ '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2',
21
+ '.ttf': 'font/ttf', '.html': 'text/html; charset=utf-8', '.md': 'text/markdown',
22
+ '.bcmap': 'application/octet-stream', '.pfb': 'application/octet-stream',
38
23
  }
39
24
 
40
- /** 图标目录(paths.ts 统一解析:config.imageDir 覆盖优先,否则插件包 assets/)。 */
41
- const imgDirOf = (config: unknown): string => imageDirOf(config, import.meta.url)
25
+ type RpcHandler = <M extends RpcMethod>(method: M, args: RpcRequestMap[M]) => Promise<unknown>
26
+ type HttpHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>
27
+ interface PluginRoute { kind: 'exact' | 'prefix'; path: string; methods: string[]; handler: HttpHandler }
42
28
 
43
- /** Monaco AMD 静态目录(随包发布,paths.ts 统一解析)。 */
44
- const vendorDir = vendorDirOf(import.meta.url)
29
+ // #region RPC
30
+ /**
31
+ * Serialize RPC results, retaining the binary preview transport.
32
+ * @author ddj 2026年09月28号
33
+ * @param res HTTP response.
34
+ * @param result RPC business result or binary envelope.
35
+ */
36
+ function sendRpc(res: ServerResponse, result: unknown): void {
37
+ const envelope = result as { ok?: boolean; binary?: { bytes?: Uint8Array; mime?: string; size?: number; version?: string } } | null
38
+ const binary = envelope?.binary
39
+ if (envelope?.ok !== true || !(binary?.bytes instanceof Uint8Array)) {
40
+ replyJson(res, 200, result)
41
+ return
42
+ }
43
+ res.statusCode = 200
44
+ res.setHeader('cache-control', 'no-store')
45
+ res.setHeader('content-type', 'application/octet-stream')
46
+ res.setHeader('x-edrv-mime', String(binary.mime ?? ''))
47
+ res.setHeader('x-edrv-version', String(binary.version ?? ''))
48
+ res.setHeader('x-edrv-size', String(binary.size ?? binary.bytes.byteLength))
49
+ res.end(Buffer.from(binary.bytes))
50
+ }
45
51
 
46
52
  /**
47
- * 注册全部 webServer 路由(带兼容护栏:前缀冲突预警 + 注册失败降级跳过)。
48
- * @author ddj 2026年08月20号
49
- * @param ctx DSH 上下文
50
- * @param config 插件配置(可选 imageDir 等)
51
- * @param handleRpc RPC 分发入口(method,args → RpcResult)
52
- * @param onWarning 兼容性警告收集(可选)
53
+ * Build a bounded RPC handler; authorization is applied by mountRoute.
54
+ * @author ddj 2026年09月28号
55
+ * @param dispatch Business dispatcher.
56
+ * @returns HTTP handler rejecting malformed bodies before dispatch.
53
57
  */
54
- export function registerRoutes(
55
- ctx: Ctx,
56
- config: unknown,
57
- handleRpc: <M extends RpcMethod>(method: M, args: RpcRequestMap[M]) => Promise<unknown>,
58
- onWarning?: (warning: string) => void,
59
- ): void {
60
- const imgDir = imgDirOf(config)
61
- const web = ctx.get('webServer')
62
- if (!web) return
58
+ function rpcHandler(dispatch: RpcHandler): HttpHandler {
59
+ /** @author ddj 2026年09月28号 @param req Admitted request. @param res HTTP response. */
60
+ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
61
+ const decoded = await readRpc(req)
62
+ if ('status' in decoded) {
63
+ replyJson(res, decoded.status, { ok: false, error: decoded.error })
64
+ return
65
+ }
66
+ const { method, args } = decoded.input
67
+ sendRpc(res, await dispatch(method as RpcMethod, args as never))
68
+ }
69
+ return handle
70
+ }
71
+ // #endregion
63
72
 
64
- resetOwnRoutes()
65
- const conflict = routeConflict(web, ROUTE_PREFIX)
66
- if (conflict) onWarning?.('兼容性:' + conflict)
73
+ // #region Static files
74
+ /**
75
+ * Check a resolved asset remains under its canonical asset root.
76
+ * @author ddj 2026年09月28号
77
+ * @param root Canonical asset directory.
78
+ * @param target Candidate canonical file path.
79
+ * @returns Whether the target lies strictly below the root.
80
+ */
81
+ function assetInside(root: string, target: string): boolean {
82
+ const rel = relative(root, target)
83
+ return rel !== '' && rel !== '..' && !rel.startsWith('../') && !rel.startsWith('..\\') && !isAbsolute(rel)
84
+ }
85
+
86
+ /**
87
+ * Serve a file with conditional caching, honoring HEAD without loading its body.
88
+ * @author ddj 2026年09月28号
89
+ * @param req Admitted GET or HEAD request.
90
+ * @param res HTTP response.
91
+ * @param target Canonical asset file.
92
+ * @param mime Content type.
93
+ */
94
+ async function sendAsset(req: IncomingMessage, res: ServerResponse, target: string, mime: string): Promise<void> {
95
+ const info = await stat(target)
96
+ if (!info.isFile()) { res.statusCode = 404; res.end(); return }
97
+ const etag = 'W/"' + info.mtimeMs.toString(16) + '-' + info.size.toString(16) + '"'
98
+ res.setHeader('etag', etag)
99
+ res.setHeader('cache-control', 'private, max-age=3600')
100
+ if (req.headers['if-none-match'] === etag) { res.statusCode = 304; res.end(); return }
101
+ res.statusCode = 200
102
+ res.setHeader('content-type', mime)
103
+ res.setHeader('content-length', info.size)
104
+ res.end(req.method === 'HEAD' ? undefined : await readFile(target))
105
+ }
67
106
 
68
- /** 护栏注册:重复路由抛错时降级为警告并跳过,不让单条路由拖垮装配。 */
69
- const guarded = (route: { kind: 'exact' | 'prefix'; path: string; handler?: unknown }, label: string): (() => void) | undefined => {
107
+ /**
108
+ * Build an asset handler that refuses traversal and symlinks outside its root.
109
+ * @author ddj 2026年09月28号
110
+ * @param directory Static assets directory.
111
+ * @param prefix URL prefix, absent for a fixed asset filename.
112
+ * @param filename Optional fixed asset name.
113
+ * @returns Authenticated route body handler.
114
+ */
115
+ function assetHandler(directory: string, prefix?: string, filename?: string): HttpHandler {
116
+ /** @author ddj 2026年09月28号 @param req Admitted request. @param res HTTP response. */
117
+ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
118
+ try {
119
+ const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
120
+ const name = filename ?? decodeURIComponent(pathname.slice(prefix!.length)).replace(/^\/+/, '')
121
+ if (!name) { res.statusCode = 404; res.end(); return }
122
+ const root = await realpath(directory)
123
+ const target = await realpath(resolve(root, name))
124
+ if (!assetInside(root, target)) { res.statusCode = 403; res.end(); return }
125
+ await sendAsset(req, res, target, VENDOR_MIME[extname(target)] ?? 'application/octet-stream')
126
+ } catch {
127
+ if (!res.headersSent) res.statusCode = 404
128
+ res.end()
129
+ }
130
+ }
131
+ return handle
132
+ }
133
+ // #endregion
134
+
135
+ /**
136
+ * Register one owned route, enforcing authentication before any asset or RPC access.
137
+ * @author ddj 2026年09月28号
138
+ * @param ctx Host context.
139
+ * @param route Plugin route descriptor.
140
+ * @param warning Optional registration diagnostic sink.
141
+ */
142
+ function mountRoute(ctx: Ctx, route: PluginRoute, warning?: (text: string) => void): void {
143
+ /** @author ddj 2026年09月28号 @param req Original HTTP request. @param res HTTP response. */
144
+ async function guarded(req: IncomingMessage, res: ServerResponse): Promise<void> {
145
+ if (!admitRoute(ctx, req, res, route.methods)) return
146
+ try { await route.handler(req, res) } catch {
147
+ if (!res.headersSent) replyJson(res, 500, { ok: false, error: 'Plugin request failed' })
148
+ else res.end()
149
+ }
150
+ }
151
+ /** @author ddj 2026年09月28号 @returns Route disposer, or undefined on a registration conflict. */
152
+ function install(): (() => void) | undefined {
70
153
  try {
71
- const dispose = web.register(route)
154
+ const dispose = ctx.get('webServer').register({ kind: route.kind, path: route.path, handler: guarded })
72
155
  noteOwnRoute(route.kind, route.path)
73
156
  return dispose
74
157
  } catch (error) {
75
- onWarning?.('兼容性:' + label + ' 注册失败(' + String(error) + ')')
158
+ warning?.('兼容性:' + route.path + ' 注册失败(' + String(error) + ')')
76
159
  return undefined
77
160
  }
78
161
  }
162
+ ctx.effect(install, 'edrv: ' + route.path)
163
+ }
79
164
 
80
- ctx.effect(() => guarded({
81
- kind: 'exact',
82
- path: RPC_PATH,
83
- handler: async (req: unknown, res: { statusCode?: number; setHeader: (k: string, v: string) => void; end: (b?: string | Uint8Array) => void }) => {
84
- try {
85
- const chunks: Uint8Array[] = []
86
- for await (const chunk of req as AsyncIterable<Uint8Array>) chunks.push(chunk)
87
- let input: { method?: string; args?: unknown } = {}
88
- try {
89
- input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
90
- } catch (error) {
91
- input = {}
92
- }
93
- const method = (typeof input.method === 'string' ? input.method : '') as RpcMethod
94
- const result = await handleRpc(method, (input.args ?? {}) as never)
95
- // 二进制预览信封(edrv.readBinary):octet-stream + 元数据头直出原始字节,
96
- // 省 base64 ~33% 体积;错误与其余方法仍走下方 JSON 分支(client 侧据此识别并回退)
97
- const bin = (result as { ok?: boolean; binary?: { bytes?: Uint8Array; mime?: string; size?: number; version?: string } } | null)?.binary
98
- if (result && (result as { ok?: boolean }).ok === true && bin?.bytes instanceof Uint8Array) {
99
- res.statusCode = 200
100
- res.setHeader('content-type', 'application/octet-stream')
101
- res.setHeader('x-edrv-mime', String(bin.mime ?? ''))
102
- res.setHeader('x-edrv-version', String(bin.version ?? ''))
103
- res.setHeader('x-edrv-size', String(bin.size ?? bin.bytes.byteLength))
104
- res.end(Buffer.from(bin.bytes))
105
- return
106
- }
107
- res.statusCode = 200
108
- res.setHeader('content-type', 'application/json')
109
- res.end(JSON.stringify(result))
110
- } catch (error) {
111
- res.statusCode = 500
112
- res.end(JSON.stringify({ ok: false, error: String(error) }))
113
- }
114
- },
115
- }, '/edrv/rpc 精确路由'), 'edrv: routes')
116
-
117
- for (const r of IMG_ROUTES) {
118
- ctx.effect(() => guarded({
119
- kind: 'exact',
120
- path: r.url,
121
- handler: async (req: unknown, res: { statusCode?: number; setHeader: (k: string, v: string) => void; end: (b?: string | Uint8Array) => void }) => {
122
- try {
123
- const body = await readFile(imgDir + '/' + r.file)
124
- res.statusCode = 200
125
- res.setHeader('content-type', 'image/png')
126
- res.setHeader('cache-control', 'no-cache')
127
- res.end(body)
128
- } catch (error) {
129
- res.statusCode = 404
130
- res.end()
131
- }
132
- },
133
- }, '静态资源 ' + r.file), 'edrv: routes')
165
+ /**
166
+ * Register plugin RPC and offline asset routes with the official connection fence.
167
+ * @author ddj 2026年09月28号
168
+ * @param ctx Host context.
169
+ * @param config Plugin configuration.
170
+ * @param handleRpc Business RPC dispatcher.
171
+ * @param onWarning Optional compatibility diagnostic sink.
172
+ */
173
+ export function registerRoutes(ctx: Ctx, config: unknown, handleRpc: RpcHandler, onWarning?: (text: string) => void): void {
174
+ const web = ctx.get('webServer')
175
+ if (!web) return
176
+ resetOwnRoutes()
177
+ const conflict = routeConflict(web, ROUTE_PREFIX)
178
+ if (conflict) onWarning?.('兼容性:' + conflict)
179
+ mountRoute(ctx, { kind: 'exact', path: RPC_PATH, methods: ['POST'], handler: rpcHandler(handleRpc) }, onWarning)
180
+ const images = imageDirOf(config, import.meta.url)
181
+ for (const image of IMG_ROUTES) {
182
+ mountRoute(ctx, { kind: 'exact', path: image.url, methods: ['GET', 'HEAD'], handler: assetHandler(images, undefined, image.file) }, onWarning)
134
183
  }
135
-
136
- // Monaco Editor AMD 构建:/edrv/vendor/* → assets/vendor/*(路径穿越防护 + 按扩展名 MIME)
137
- // ETag 协商缓存:弱 etag(mtime+size),配合 max-age 让浏览器 304 复用,插件升级内容变化自动失效。
138
- ctx.effect(() => guarded({
139
- kind: 'prefix',
140
- path: VENDOR_PREFIX,
141
- handler: async (req: { method?: string; url?: string; headers?: Record<string, string | string[] | undefined> }, res: { statusCode?: number; setHeader: (k: string, v: string) => void; end: (b?: string | Uint8Array) => void }) => {
142
- try {
143
- if (req.method !== 'GET' && req.method !== 'HEAD') {
144
- res.statusCode = 405
145
- res.end()
146
- return
147
- }
148
- const pathname = new URL(req.url ?? '/', 'http://x').pathname
149
- const rel = decodeURIComponent(pathname.slice(VENDOR_PREFIX.length)).replace(/^\/+/, '')
150
- if (!rel) { res.statusCode = 404; res.end(); return }
151
- const target = resolve(normalize(join(vendorDir, rel)))
152
- if (target !== vendorDir && !target.startsWith(vendorDir + sep)) {
153
- res.statusCode = 403
154
- res.end()
155
- return
156
- }
157
- const info = await stat(target).catch(() => null)
158
- if (!info || !info.isFile()) { res.statusCode = 404; res.end(); return }
159
- const etag = 'W/"' + info.mtimeMs.toString(16) + '-' + info.size.toString(16) + '"'
160
- const reqEtag = (() => {
161
- const raw = req.headers?.['if-none-match']
162
- if (Array.isArray(raw)) return raw[0]
163
- return raw
164
- })()
165
- if (reqEtag === etag) {
166
- res.statusCode = 304
167
- res.setHeader('etag', etag)
168
- res.end()
169
- return
170
- }
171
- const body = await readFile(target)
172
- res.statusCode = 200
173
- res.setHeader('content-type', VENDOR_MIME[extname(target)] ?? 'application/octet-stream')
174
- res.setHeader('cache-control', 'public, max-age=3600')
175
- res.setHeader('etag', etag)
176
- res.end(body)
177
- } catch (error) {
178
- res.statusCode = 404
179
- res.end()
180
- }
181
- },
182
- }, '/edrv/vendor 前缀路由'), 'edrv: routes')
184
+ mountRoute(ctx, { kind: 'prefix', path: VENDOR_PREFIX, methods: ['GET', 'HEAD'], handler: assetHandler(vendorDirOf(import.meta.url), VENDOR_PREFIX) }, onWarning)
183
185
  }
package/src/rpc.ts CHANGED
@@ -38,7 +38,7 @@ import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS, sectio
38
38
  import { INTEGRATION_BASE_DEFAULT } from './shared/integration.js'
39
39
  import { shellMenuRegister, shellMenuRemove, shellMenuStatus } from './integrate.js'
40
40
  import { unityAdd, unityInstall, unityList, unityRemove } from './unityBridge.js'
41
- import { handoffOpen, pendingState, pollPending } from './externalHandoff.js'
41
+ import { ackPending, handoffOpen, pendingState, pollPending } from './externalHandoff.js'
42
42
  import { buildReport } from './compat.js'
43
43
  import { findProfileDir, readDevForm, setDevForm } from './devForm.js'
44
44
  import { normalizeRel } from './tree.js'
@@ -296,8 +296,8 @@ function activeSessionIds(ctx: Ctx): Set<string> {
296
296
  }
297
297
 
298
298
  /** profile patch 文件定位(依赖本插件的 profile,同 devForm)。 */
299
- async function patchFileInfo(): Promise<{ profileDir?: string; patchPath?: string }> {
300
- const profileDir = findProfileDir()
299
+ async function patchFileInfo(ctx: Ctx): Promise<{ profileDir?: string; patchPath?: string }> {
300
+ const profileDir = findProfileDir(ctx)
301
301
  if (!profileDir) return {}
302
302
  return { profileDir, patchPath: join(profileDir, 'cordis.patch.yml') }
303
303
  }
@@ -1009,12 +1009,12 @@ export function buildHandlers(
1009
1009
  return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool), integrationBaseUrl: await integrationBaseUrlOf(ctx), revision: section?.revision }
1010
1010
  },
1011
1011
  'compat': async () => ({ ok: true, report: await buildReport(ctx) }),
1012
- 'vscode.devFormGet': async () => ({ ok: true, devForm: readDevForm() }),
1012
+ 'vscode.devFormGet': async () => ({ ok: true, devForm: readDevForm(ctx) }),
1013
1013
  'vscode.devFormSet': async (args) => {
1014
1014
  try {
1015
1015
  const result = await setDevForm(ctx, args.enabled === true, args.path)
1016
1016
  if (!result.ok) return { ok: false, error: result.error ?? '切换开发形态失败' }
1017
- return { ok: true, devForm: readDevForm(), restart: result.restart }
1017
+ return { ok: true, devForm: readDevForm(ctx), restart: result.restart }
1018
1018
  } catch (error) {
1019
1019
  return { ok: false, error: String(error) }
1020
1020
  }
@@ -1064,7 +1064,7 @@ export function buildHandlers(
1064
1064
  },
1065
1065
  'edrv.unity.install': async (args) => {
1066
1066
  try {
1067
- return { ok: true, project: await unityInstall(args.path) }
1067
+ return { ok: true, project: await unityInstall(args.path, undefined, { ctx, baseUrl: await integrationBaseUrlOf(ctx) }) }
1068
1068
  } catch (error) {
1069
1069
  return { ok: false, error: String(error) }
1070
1070
  }
@@ -1086,10 +1086,11 @@ export function buildHandlers(
1086
1086
  ok: true,
1087
1087
  ...handoffOpen({ paths: args.paths, line: args.line, column: args.column }),
1088
1088
  }),
1089
- 'edrv.external.pending': async () => ({ ok: true, open: pollPending() }),
1089
+ 'edrv.external.pending': async (args) => ({ ok: true, open: pollPending(args) }),
1090
+ 'edrv.external.ack': async (args) => ({ ok: true, accepted: ackPending(args) }),
1090
1091
  'edrv.external.pendingState': async (args) => ({
1091
1092
  ok: true,
1092
- delivered: pendingState(args.token, args.take === true).delivered,
1093
+ ...pendingState(args.token, args.take === true),
1093
1094
  }),
1094
1095
  'edrv.perf.inventory': async () => {
1095
1096
  try {
@@ -1163,7 +1164,7 @@ export function buildHandlers(
1163
1164
  },
1164
1165
  'edrv.perf.configGet': async () => {
1165
1166
  try {
1166
- const { profileDir, patchPath } = await patchFileInfo()
1167
+ const { profileDir, patchPath } = await patchFileInfo(ctx)
1167
1168
  const block = perfConfigBlock()
1168
1169
  if (!patchPath) return { ok: true, applied: false, block }
1169
1170
  const text = await readFile(patchPath, 'utf8').catch(() => '')
@@ -1175,7 +1176,7 @@ export function buildHandlers(
1175
1176
  },
1176
1177
  'edrv.perf.configApply': async () => {
1177
1178
  try {
1178
- const { patchPath } = await patchFileInfo()
1179
+ const { patchPath } = await patchFileInfo(ctx)
1179
1180
  if (!patchPath) return { ok: false, error: '未找到依赖本插件的 profile(检查 DSH_HOME/profiles)' }
1180
1181
  const text = await readFile(patchPath, 'utf8').catch(() => '')
1181
1182
  const backup = patchPath + '.bak-' + Date.now()
@@ -1189,7 +1190,7 @@ export function buildHandlers(
1189
1190
  },
1190
1191
  'edrv.perf.configUndo': async () => {
1191
1192
  try {
1192
- const { patchPath } = await patchFileInfo()
1193
+ const { patchPath } = await patchFileInfo(ctx)
1193
1194
  if (!patchPath) return { ok: false, error: '未找到依赖本插件的 profile(检查 DSH_HOME/profiles)' }
1194
1195
  const backup = await latestPatchBackup(patchPath)
1195
1196
  if (backup) {
@@ -1305,7 +1306,7 @@ export async function handleRpc<M extends RpcMethod>(
1305
1306
  dapHandlers?: Partial<RpcHandlerMap>,
1306
1307
  ): Promise<RpcResult<M>> {
1307
1308
  const handlers = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers, dapHandlers)
1308
- const handler = handlers[method]
1309
- if (!handler) return { ok: false, error: '未知方法: ' + String(method) } as RpcResult<M>
1309
+ const handler = Object.hasOwn(handlers, method) ? handlers[method] : undefined
1310
+ if (typeof handler !== 'function') return { ok: false, error: '未知方法: ' + String(method) } as RpcResult<M>
1310
1311
  return handler(args)
1311
1312
  }
@@ -0,0 +1,19 @@
1
+ /** Browser application URL resolution shared by transport and asset loaders. @author ddj 2026年09月28号 */
2
+
3
+ /**
4
+ * Resolve a product-local path under the current application's document base.
5
+ * Supports HTTP reverse-proxy prefixes and the official Desktop dsh-app origin.
6
+ * @author ddj 2026年09月28号
7
+ * @param path Root-shaped internal endpoint or asset path; never an external URL.
8
+ * @param base Explicit document base for tests; otherwise document.baseURI or location.href.
9
+ * @returns Absolute application URL, or the original root path outside a browser.
10
+ */
11
+ export function appUrl(path: string, base?: string): string {
12
+ if (!path.startsWith('/') || path.startsWith('//')) throw new Error('Expected an application-local path')
13
+ const source = base ?? (typeof document !== 'undefined' ? document.baseURI : undefined)
14
+ ?? (typeof location !== 'undefined' ? location.href : undefined)
15
+ if (!source) return path
16
+ const root = new URL('.', source)
17
+ if (!['http:', 'https:', 'dsh-app:'].includes(root.protocol)) throw new Error('Unsupported application origin')
18
+ return new URL(path.slice(1), root).href
19
+ }
@@ -21,6 +21,77 @@ export const EDRV_PARAM_KEYS = [EDRV_OPEN_FLAG, EDRV_PATHS_KEY, EDRV_LINE_KEY, E
21
21
  /** 路径分隔符(编码后路径不含裸逗号,分隔安全)。 */
22
22
  export const PATHS_SEPARATOR = ','
23
23
 
24
+ export const OPEN_TTL_MS = 60_000
25
+ export const OPEN_MAX_BYTES = 65_536
26
+ export const OPEN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
27
+
28
+ export interface InboxOpen extends OpenParams {
29
+ version: 1
30
+ requestId: string
31
+ profile: string
32
+ createdAt: number
33
+ }
34
+
35
+ export interface InboxAck {
36
+ version: 1
37
+ requestId: string
38
+ profile: string
39
+ createdAt: number
40
+ success: boolean
41
+ error?: string
42
+ }
43
+
44
+ /**
45
+ * Detect the host platform without importing node builtins.
46
+ * @author ddj 2026年09月28号
47
+ * @returns Platform name, or an empty string outside Node.
48
+ */
49
+ function platformName(): string {
50
+ return typeof process === 'object' && process !== null ? String(process.platform) : ''
51
+ }
52
+
53
+ /**
54
+ * Compare profile directories by physical identity, not by spelling.
55
+ * Windows accepts either separator and folds case; other platforms stay exact.
56
+ * @author ddj 2026年09月28号
57
+ * @param left Declared profile directory.
58
+ * @param right Expected profile directory.
59
+ * @param platform Platform name for separator and case rules.
60
+ * @returns Whether both name the same profile.
61
+ */
62
+ export function sameProfile(left: string, right: string, platform = platformName()): boolean {
63
+ if (platform === 'win32') {
64
+ const fold = (value: string) => value.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
65
+ return fold(left) === fold(right)
66
+ }
67
+ return left.replace(/\/+$/, '') === right.replace(/\/+$/, '')
68
+ }
69
+
70
+ /** @public @author ddj 2026年09月28号
71
+ * Validate the open-only envelope before accepting filesystem input.
72
+ * @param value Parsed untrusted JSON.
73
+ * @param profile Exact expected profile directory.
74
+ * @param now Current epoch milliseconds.
75
+ * @param platform Platform name used for profile identity.
76
+ * @returns A typed request or null.
77
+ */
78
+ export function parseInboxOpen(value: unknown, profile: string, now = Date.now(), platform = platformName()): InboxOpen | null {
79
+ if (!value || typeof value !== 'object') return null
80
+ const row = value as Record<string, unknown>
81
+ const keys = ['version', 'requestId', 'profile', 'paths', 'line', 'column', 'createdAt']
82
+ if (Object.keys(row).some((key) => !keys.includes(key))) return null
83
+ if (row.version !== 1 || typeof row.profile !== 'string' || !sameProfile(row.profile, profile, platform)
84
+ || typeof row.requestId !== 'string' || !OPEN_ID.test(row.requestId)) return null
85
+ if (typeof row.createdAt !== 'number' || !Number.isSafeInteger(row.createdAt)) return null
86
+ if (now - row.createdAt >= OPEN_TTL_MS || row.createdAt > now + 5000) return null
87
+ if (!Array.isArray(row.paths) || !row.paths.length || row.paths.length > 20) return null
88
+ if (!row.paths.every((path) => typeof path === 'string' && path.length <= 8192 && !/[\u0000-\u001f]/.test(path) && /^(?:\/|[a-z]:[\\/]|\\\\)/i.test(path))) return null
89
+ for (const key of ['line', 'column']) {
90
+ if (row[key] !== undefined && (!Number.isSafeInteger(row[key]) || Number(row[key]) < 1)) return null
91
+ }
92
+ return row as unknown as InboxOpen
93
+ }
94
+
24
95
  /** 一次外部深链请求(解析结果)。 */
25
96
  export interface OpenParams {
26
97
  /** 待打开路径(已解码,首个路径可携带行列)。 */
@@ -58,6 +58,8 @@ export interface UnityProjectEntry {
58
58
  upToDate: boolean
59
59
  /** 项目目录不存在。 */
60
60
  missingDir: boolean
61
+ /** 安装时绑定的本机 profile bridge 配置文件。 */
62
+ bridgeConfigPath?: string
61
63
  /** 非法原因(非 Unity 项目根等)。 */
62
64
  error?: string
63
65
  }