dsh-users-open-source-workspace 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KAIROSLLL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # dsh-users-open-source-workspace
2
+
3
+ `dsh-users-open-source` 的伴生插件:受理 `POST /dsh-users-open-source/open-workspace`,在系统文件管理器里
4
+ 打开**用户当前正在用的那个工作区目录**(Web 界面上就是设置页「开源用户」里的 **云端备份** 按钮)。
5
+
6
+ ## 目录是怎么定的
7
+
8
+ 按可靠性依次尝试,任一命中即用:
9
+
10
+ 1. 请求里带 `sessionId` → 命中该会话所属的工作区(最准);
11
+ 2. 会话日志分桶目录里**最新写入**的那个桶 → 编码回工作区路径
12
+ (例如 `D:\work\my-project` ⇄ `--D-work-my-project--`)。当前会话一直在写日志,所以这条能顶住 id 缺失;
13
+ 3. 会话投影缓存 `$DSH_HOME/storages/session_projcache/sessions/*.json` 里最新那份的 `cwd`;
14
+ 4. 注册表里 `updatedAt` 最新的工作区;
15
+ 5. 全落空 → 注册表第一个工作区。
16
+
17
+ 客户端只能递 `sessionId`,**路径永远由宿主从 DSH 自己的账本里取**,所以不存在
18
+ 「递个路径就打开任意目录」的面。打开动作走宿主自己的 `ctx.subprocess`,目录作为 argv
19
+ 里的一项直传、不经 shell,所以空格、中文、`&` 都不会被当成命令。
20
+
21
+ ## 为什么 Windows 上要绕一圈 PowerShell
22
+
23
+ DSH 以**隐藏方式**启动插件的子进程 —— 直接 `spawn('explorer.exe', [dir])` 得到的窗口
24
+ 确实存在(COM 能枚举到 `CabinetWClass`),但 `IsWindowVisible` 是 `false`:**看不见**。
25
+ 后台进程再直接调 `SetForegroundWindow` 也会被 Windows 拒绝。
26
+
27
+ 所以 Windows 上改走包内的 `open-folder.ps1` + `focus.cs`:用 `Shell.Application.Explore`
28
+ 打开目录,再用 `AttachThreadInput` + `ShowWindow(SW_SHOW/SW_RESTORE)` +
29
+ `BringWindowToTop` + `SetForegroundWindow` 把窗口真正显示并顶到前台(实测:窗口
30
+ `visible` 由 false 变 true,前台窗口变成「工作区名 - 文件资源管理器」)。
31
+
32
+ ## 为什么是独立的包
33
+
34
+ `dsh-users-open-source` 带 `dsh.client` 客户端半边,而 `dsh-client-modules` 按「模块最近的
35
+ package.json」判定来源包 —— 同一个包里再挂第二个 loader 入口:
36
+
37
+ - name 写包内子路径 → loader 解析不了,entry 报 `failed to import`;
38
+ - name 写 file: URL → 被算成本包第二个 Loader 源,启动直接崩:
39
+ `client-modules: package dsh-users-open-source resolves from multiple active Loader sources`。
40
+
41
+ 独立成包后 entry name 就是包名,两边都干净,宿主侧改动也不必等 dsh web 重启。
42
+
43
+ ## 安装
44
+
45
+ ```powershell
46
+ # 通常不需要单独装:它由主包 dsh-users-open-source 作为 dependency 带上来
47
+ dsh plugin --profile desktop add "link:<本仓库>\packages\dsh-users-open-source-workspace"
48
+ ```
49
+
50
+ ## 自检
51
+
52
+ ```powershell
53
+ node tests/smoke.mjs
54
+ ```
55
+
56
+ 9 项:路由注册、sessionId 命中、三条兜底线索各自命中、全落空退第一个、
57
+ 目录缺失、无工作区、非 POST 405。
package/lib/focus.cs ADDED
@@ -0,0 +1,44 @@
1
+ using System;
2
+ using System.Text;
3
+ using System.Runtime.InteropServices;
4
+
5
+ /// <summary>
6
+ /// Win32 helpers for "open a folder and make sure its window is actually visible".
7
+ /// DSH starts plugin subprocesses hidden, so an explorer window spawned that way is
8
+ /// created with visible = false; plain SetForegroundWindow from a background process
9
+ /// is refused by Windows, hence the AttachThreadInput trick.
10
+ /// </summary>
11
+ public class FolderFocus
12
+ {
13
+ public delegate bool EnumProc(IntPtr hwnd, IntPtr lparam);
14
+
15
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lparam);
16
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hwnd, StringBuilder text, int max);
17
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(IntPtr hwnd, StringBuilder text, int max);
18
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hwnd);
19
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hwnd);
20
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hwnd, int cmd);
21
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hwnd);
22
+ [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hwnd);
23
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
24
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, IntPtr pid);
25
+ [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint attach, uint attachTo, bool attachFlag);
26
+ [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hwnd, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags);
27
+ [DllImport("user32.dll")] public static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo);
28
+ [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
29
+
30
+ /// <summary>VK_MENU (ALT). A synthetic tap makes Windows treat the focus change as user-driven.</summary>
31
+ public const byte VK_MENU = 0x12;
32
+
33
+ /// <summary>KEYEVENTF_KEYUP.</summary>
34
+ public const uint KEYEVENTF_KEYUP = 0x0002;
35
+
36
+ /// <summary>HWND_TOPMOST: keep the window above non-topmost windows.</summary>
37
+ public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
38
+
39
+ /// <summary>HWND_NOTOPMOST: return the window to the normal band.</summary>
40
+ public static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2);
41
+
42
+ /// <summary>SWP_NOSIZE | SWP_NOMOVE | SWP_SHOWWINDOW.</summary>
43
+ public const uint SWP_KEEP = 0x0001 | 0x0002 | 0x0040;
44
+ }
package/lib/index.js ADDED
@@ -0,0 +1,423 @@
1
+ /**
2
+ * dsh-users-open-source-workspace — 「云端备份」的宿主半边(打开本地工作区目录)。
3
+ *
4
+ * 干什么:受理 `POST /dsh-users-open-source/open-workspace`,在系统文件管理器里打开
5
+ * **用户当前正在用的那个工作区目录**。
6
+ *
7
+ * 目录怎么定的(按可靠性排序,任何一步都只从 DSH 自己的账本里取路径):
8
+ * 1. 客户端递来的 sessionId 命中的工作区(最准,但客户端不一定拿得到 id);
9
+ * 2. 最近有写入的会话目录 → 反解它所属的 workspace bucket(`$DSH_HOME/sessions/<bucket>/`)
10
+ * → 匹配已注册工作区。当前正在跑的会话日志一直在写,所以这一条能顶住 id 缺失;
11
+ * 3. 注册表里的第一个工作区。
12
+ * 客户端永远只能递 sessionId,**路径不由客户端决定**,所以不存在「递个路径就打开任意目录」。
13
+ *
14
+ * 为什么是**独立的包**(而不是 dsh-users-open-source 包里的第二个 entry):
15
+ * dsh-users-open-source 带 `dsh.client` 客户端半边,`dsh-client-modules` 按「模块最近的
16
+ * package.json」判定来源包 —— 同包内再挂第二个 loader 入口,无论 name 写包内子路径
17
+ * (loader 解析不了,报 failed to import)还是写 file: URL(被算成第二个 Loader 源,
18
+ * `package dsh-users-open-source resolves from multiple active Loader sources`,启动崩),
19
+ * 都走不通。独立成包后 entry name 就是包名,两边都干净。
20
+ * 顺带:宿主的「云端备份」按钮改用本条路由,所以它不必等 dsh web 重启。
21
+ *
22
+ * @module dsh-users-open-source-workspace
23
+ */
24
+ import { existsSync } from 'node:fs'
25
+ import { readdir, readFile, stat } from 'node:fs/promises'
26
+ import { homedir } from 'node:os'
27
+ import { dirname, join } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ /** 插件名(= 包名 = cordis loader 行 id)。 */
31
+ export const name = 'dsh-users-open-source-workspace'
32
+ /** 依赖宿主 webServer(挂路由)与 subprocess(起文件管理器)。 */
33
+ export const inject = ['webServer', 'subprocess']
34
+
35
+ /** 路由路径(客户端只认这一个口)。 */
36
+ const OPEN_PATH = '/dsh-users-open-source/open-workspace'
37
+
38
+ /** 本文件所在目录(`open-folder.ps1` 与 `focus.cs` 都放在这里)。 */
39
+ const HERE = dirname(fileURLToPath(import.meta.url))
40
+
41
+ /**
42
+ * 系统自带 PowerShell 的绝对路径(不走 PATH:DSH 给子进程的 PATH 可能是清理过的)。
43
+ * @returns {string} powershell.exe 的绝对路径。
44
+ */
45
+ function powershellPath() {
46
+ const root = typeof process.env.SystemRoot === 'string' && process.env.SystemRoot !== ''
47
+ ? process.env.SystemRoot
48
+ : 'C:\\Windows'
49
+ return `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
50
+ }
51
+
52
+ /**
53
+ * 解析 DSH home(与内核 `dshHomePath()` 同优先级:$DSH_HOME 优先,否则 ~/.dsh)。
54
+ * @returns {string} 绝对的 DSH home 路径。
55
+ */
56
+ function dshHome() {
57
+ const configured = process.env.DSH_HOME
58
+ if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
59
+ return join(homedir(), '.dsh')
60
+ }
61
+
62
+ /**
63
+ * 写一个 JSON 响应。
64
+ * @param {import('node:http').ServerResponse} res - 响应对象。
65
+ * @param {number} status - HTTP 状态码。
66
+ * @param {unknown} payload - 要序列化的值。
67
+ */
68
+ function sendJson(res, status, payload) {
69
+ const body = Buffer.from(JSON.stringify(payload), 'utf8')
70
+ res.writeHead(status, {
71
+ 'content-type': 'application/json; charset=utf-8',
72
+ 'cache-control': 'no-store',
73
+ 'content-length': String(body.length)
74
+ })
75
+ res.end(body)
76
+ }
77
+
78
+ /**
79
+ * 读取请求体并解析 JSON,带大小上限。
80
+ * @param {import('node:http').IncomingMessage} req - 请求对象。
81
+ * @param {number} [limit] - 最大字节数。
82
+ * @returns {Promise<Record<string, unknown>>} 解析后的对象。
83
+ */
84
+ function readJsonBody(req, limit = 8192) {
85
+ return new Promise((resolve, reject) => {
86
+ const chunks = []
87
+ let size = 0
88
+ req.on('data', (chunk) => {
89
+ size += chunk.length
90
+ if (size > limit) {
91
+ reject(new Error('body too large'))
92
+ req.destroy()
93
+ return
94
+ }
95
+ chunks.push(chunk)
96
+ })
97
+ req.on('end', () => {
98
+ const text = Buffer.concat(chunks).toString('utf8')
99
+ if (text.trim() === '') {
100
+ resolve({})
101
+ return
102
+ }
103
+ try {
104
+ const parsed = JSON.parse(text)
105
+ resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
106
+ } catch (error) {
107
+ reject(new Error(`invalid JSON body: ${String(error && error.message)}`))
108
+ }
109
+ })
110
+ req.on('error', reject)
111
+ })
112
+ }
113
+
114
+ /**
115
+ * 在系统文件管理器里打开一个目录。
116
+ *
117
+ * Windows:走包内的 `open-folder.ps1`。**不能**直接 spawn `explorer.exe` ——
118
+ * DSH 以隐藏方式启动插件的子进程,explorer 派生出来的窗口会带着 `visible=false`
119
+ * (实测:窗口存在、COM 能枚举到,但就是看不见),后台进程直接调
120
+ * SetForegroundWindow 也会被 Windows 拒绝。脚本里用
121
+ * Shell.Application.Explore + AttachThreadInput/ShowWindow/SetForegroundWindow
122
+ * 把窗口真正显出来并顶到前台。
123
+ *
124
+ * 其它平台:直接用系统自带的打开器,目录走 argv,不经 shell。
125
+ * @param {import('@deepseek-ai/cordis').Context} ctx - 宿主上下文。
126
+ * @param {string} directory - 要打开的绝对目录。
127
+ * @returns {string} 实际用的方式(写进日志与响应)。
128
+ */
129
+ function openInFileManager(ctx, directory) {
130
+ if (process.platform === 'win32') {
131
+ const script = join(HERE, 'open-folder.ps1')
132
+ if (process.env.DSH_NET_SPEED_DRY_OPEN === '1') return 'powershell:open-folder.ps1'
133
+ const handle = ctx.subprocess.spawn({
134
+ argv: [
135
+ powershellPath(),
136
+ '-NoProfile',
137
+ '-NonInteractive',
138
+ '-ExecutionPolicy',
139
+ 'Bypass',
140
+ '-File',
141
+ script,
142
+ directory
143
+ ],
144
+ cwd: directory,
145
+ stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' },
146
+ graceMs: 20000
147
+ })
148
+ void handle.done.catch(() => {})
149
+ return 'powershell:open-folder.ps1'
150
+ }
151
+
152
+ const command = process.platform === 'darwin' ? '/usr/bin/open' : '/usr/bin/xdg-open'
153
+ if (process.env.DSH_NET_SPEED_DRY_OPEN === '1') return command
154
+ const handle = ctx.subprocess.spawn({
155
+ argv: [command, directory],
156
+ cwd: directory,
157
+ stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' },
158
+ graceMs: 3000
159
+ })
160
+ void handle.done.catch(() => {})
161
+ return command
162
+ }
163
+
164
+ /**
165
+ * 把一个工作区路径编码成 DSH 的会话分桶目录名。
166
+ *
167
+ * 观察到的规则:`D:\work\my-project` → `--D-work-my-project--`(`:` 与 `\` `/` 都变 `-`,
168
+ * 原有的 `-` 保留,两端各包 `--`)。用于把会话目录反查回工作区。
169
+ * @param {string} path - 工作区绝对路径。
170
+ * @returns {string} 分桶目录名。
171
+ */
172
+ function encodeWorkspaceBucket(path) {
173
+ return `--${path.replace(/[:\\/]/g, '-')}--`
174
+ }
175
+
176
+ /**
177
+ * 读一个会话目录里最新的文件写入时间。
178
+ * @param {string} directory - 会话目录。
179
+ * @returns {Promise<number>} 最新 mtime(毫秒),读不到时 0。
180
+ */
181
+ async function newestWriteIn(directory) {
182
+ let newest = 0
183
+ let entries = []
184
+ try {
185
+ entries = await readdir(directory, { withFileTypes: true })
186
+ } catch {
187
+ return 0
188
+ }
189
+ for (const entry of entries) {
190
+ if (!entry.isFile()) continue
191
+ try {
192
+ const info = await stat(join(directory, entry.name))
193
+ if (info.mtimeMs > newest) newest = info.mtimeMs
194
+ } catch {
195
+ /* 单个文件读不到不影响整体判断 */
196
+ }
197
+ }
198
+ return newest
199
+ }
200
+
201
+ /**
202
+ * 两个路径是否指同一个目录(Windows 大小写不敏感、容忍尾部分隔符)。
203
+ * @param {unknown} a - 路径一。
204
+ * @param {unknown} b - 路径二。
205
+ * @returns {boolean} 相同则 true。
206
+ */
207
+ function samePath(a, b) {
208
+ if (typeof a !== 'string' || typeof b !== 'string') return false
209
+ const norm = (value) => value.replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase()
210
+ return norm(a) === norm(b)
211
+ }
212
+
213
+ /**
214
+ * 在一份 JSON 里找第一个非空 `cwd` 字符串。
215
+ * 投影缓存的结构随内核版本会变,所以不写死层级,只限深度。
216
+ * @param {unknown} value - 任意 JSON 值。
217
+ * @param {number} [depth] - 递归深度上限。
218
+ * @returns {string} 命中的 cwd,或空串。
219
+ */
220
+ function findCwdDeep(value, depth = 0) {
221
+ if (depth > 5 || value === null || typeof value !== 'object') return ''
222
+ if (Array.isArray(value)) {
223
+ for (const item of value) {
224
+ const hit = findCwdDeep(item, depth + 1)
225
+ if (hit !== '') return hit
226
+ }
227
+ return ''
228
+ }
229
+ const record = value
230
+ if (typeof record.cwd === 'string' && record.cwd !== '') return record.cwd
231
+ for (const item of Object.values(record)) {
232
+ const hit = findCwdDeep(item, depth + 1)
233
+ if (hit !== '') return hit
234
+ }
235
+ return ''
236
+ }
237
+
238
+ /**
239
+ * 线索 1:会话日志分桶 —— 当前会话一直在写,最新 mtime 的桶就是它。
240
+ * @param {object[]} workspaces - 已注册工作区。
241
+ * @returns {Promise<{path:string,title:string,source:'recent'}|null>} 命中结果。
242
+ */
243
+ async function recentFromSessionBuckets(workspaces) {
244
+ const root = join(dshHome(), 'sessions')
245
+ let buckets = []
246
+ try {
247
+ buckets = await readdir(root, { withFileTypes: true })
248
+ } catch {
249
+ return null
250
+ }
251
+ let best = null
252
+ for (const bucket of buckets) {
253
+ if (!bucket.isDirectory()) continue
254
+ const bucketDir = join(root, bucket.name)
255
+ let sessions = []
256
+ try {
257
+ sessions = await readdir(bucketDir, { withFileTypes: true })
258
+ } catch {
259
+ continue
260
+ }
261
+ for (const session of sessions) {
262
+ if (!session.isDirectory()) continue
263
+ const mtime = await newestWriteIn(join(bucketDir, session.name))
264
+ if (mtime > 0 && (best === null || mtime > best.mtime)) {
265
+ best = { bucket: bucket.name, mtime }
266
+ }
267
+ }
268
+ }
269
+ if (best === null) return null
270
+ const hit = workspaces.find((workspace) => {
271
+ const path = workspace === null || workspace === undefined ? undefined : workspace.path
272
+ return typeof path === 'string' && encodeWorkspaceBucket(path) === best.bucket
273
+ })
274
+ if (hit === undefined || hit === null || typeof hit.path !== 'string') return null
275
+ return { path: hit.path, title: String(hit.title ?? ''), source: 'recent' }
276
+ }
277
+
278
+ /**
279
+ * 线索 2:会话投影缓存 —— 最新那个文件里带着明确的 cwd。
280
+ * @param {object[]} workspaces - 已注册工作区。
281
+ * @returns {Promise<{path:string,title:string,source:'recent'}|null>} 命中结果。
282
+ */
283
+ async function recentFromProjectionCache(workspaces) {
284
+ const dir = join(dshHome(), 'storages', 'session_projcache', 'sessions')
285
+ let entries = []
286
+ try {
287
+ entries = await readdir(dir, { withFileTypes: true })
288
+ } catch {
289
+ return null
290
+ }
291
+ let best = null
292
+ for (const entry of entries) {
293
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue
294
+ const file = join(dir, entry.name)
295
+ try {
296
+ const info = await stat(file)
297
+ if (best === null || info.mtimeMs > best.mtime) best = { file, mtime: info.mtimeMs }
298
+ } catch {
299
+ /* 单个文件读不到不影响整体判断 */
300
+ }
301
+ }
302
+ if (best === null) return null
303
+ let cwd = ''
304
+ try {
305
+ cwd = findCwdDeep(JSON.parse(await readFile(best.file, 'utf8')))
306
+ } catch {
307
+ return null
308
+ }
309
+ if (cwd === '') return null
310
+ const hit = workspaces.find((workspace) => {
311
+ const path = workspace === null || workspace === undefined ? undefined : workspace.path
312
+ return samePath(path, cwd)
313
+ })
314
+ if (hit !== undefined && hit !== null && typeof hit.path === 'string') {
315
+ return { path: hit.path, title: String(hit.title ?? ''), source: 'recent' }
316
+ }
317
+ // 缓存里的目录不在注册表里也认(他可能刚加进来还没入库)
318
+ if (existsSync(cwd)) return { path: cwd, title: '', source: 'recent' }
319
+ return null
320
+ }
321
+
322
+ /**
323
+ * 找"用户当前正在用"的工作区(客户端拿不到 sessionId 时的救命绳)。
324
+ *
325
+ * 三条线索依次尝试,任一命中即返回:
326
+ * 1. 会话日志分桶(当前会话一直在写,最新的桶就是它);
327
+ * 2. 会话投影缓存(文件里带明确的 cwd);
328
+ * 3. 注册表里 updatedAt 最新的工作区。
329
+ * 全落空时返回 null,调用方退到"注册表里第一个工作区"。
330
+ * @param {import('@deepseek-ai/cordis').Context} ctx - 宿主上下文。
331
+ * @param {object[]} workspaces - 已注册工作区。
332
+ * @returns {Promise<{path:string,title:string,source:'recent'}|null>} 命中结果。
333
+ */
334
+ async function findRecentlyActiveWorkspace(ctx, workspaces) {
335
+ const fromBuckets = await recentFromSessionBuckets(workspaces)
336
+ if (fromBuckets !== null) return fromBuckets
337
+
338
+ const fromCache = await recentFromProjectionCache(workspaces)
339
+ if (fromCache !== null) return fromCache
340
+
341
+ let newest = null
342
+ for (const workspace of workspaces) {
343
+ if (workspace === null || workspace === undefined) continue
344
+ const stamp = typeof workspace.updatedAt === 'string' ? workspace.updatedAt : ''
345
+ if (stamp === '') continue
346
+ if (newest === null || stamp > newest.stamp) newest = { workspace, stamp }
347
+ }
348
+ if (newest !== null && typeof newest.workspace.path === 'string') {
349
+ return { path: newest.workspace.path, title: String(newest.workspace.title ?? ''), source: 'recent' }
350
+ }
351
+ return null
352
+ }
353
+
354
+ /**
355
+ * 定出要打开的工作区目录(三步兜底,见模块头)。
356
+ * @param {import('@deepseek-ai/cordis').Context} ctx - 宿主上下文。
357
+ * @param {string} sessionId - 客户端报的当前会话 id(可为空)。
358
+ * @returns {Promise<{path:string,title:string,source:'session'|'recent'|'first'}|null>} 结果。
359
+ */
360
+ async function resolveWorkspaceDirectory(ctx, sessionId) {
361
+ const registry = ctx.get('workspaceRegistry')
362
+ if (registry === undefined || typeof registry.list !== 'function') return null
363
+ let workspaces = []
364
+ try {
365
+ workspaces = registry.list()
366
+ } catch {
367
+ return null
368
+ }
369
+ if (!Array.isArray(workspaces) || workspaces.length === 0) return null
370
+
371
+ if (sessionId !== '') {
372
+ const hit = workspaces.find((workspace) => {
373
+ const ids = workspace === null || workspace === undefined ? undefined : workspace.sessionIds
374
+ return Array.isArray(ids) ? ids.includes(sessionId) : false
375
+ })
376
+ if (hit !== undefined && hit !== null && typeof hit.path === 'string') {
377
+ return { path: hit.path, title: String(hit.title ?? ''), source: 'session' }
378
+ }
379
+ }
380
+
381
+ const recent = await findRecentlyActiveWorkspace(ctx, workspaces)
382
+ if (recent !== null) return recent
383
+
384
+ const first = workspaces[0]
385
+ if (first === undefined || first === null || typeof first.path !== 'string') return null
386
+ return { path: first.path, title: String(first.title ?? ''), source: 'first' }
387
+ }
388
+
389
+ /**
390
+ * 插件入口:挂上「打开工作区目录」这一条路由。
391
+ * @param {import('@deepseek-ai/cordis').Context} ctx - 宿主上下文。
392
+ * @returns {void}
393
+ */
394
+ export function apply(ctx) {
395
+ ctx.effect(() => ctx.webServer.register({
396
+ kind: 'exact',
397
+ path: OPEN_PATH,
398
+ handler: async (req, res) => {
399
+ if (req.method !== 'POST') {
400
+ sendJson(res, 405, { ok: false, error: 'POST only' })
401
+ return
402
+ }
403
+ try {
404
+ const body = await readJsonBody(req)
405
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
406
+ const target = await resolveWorkspaceDirectory(ctx, sessionId)
407
+ if (target === null) {
408
+ sendJson(res, 200, { ok: false, error: 'no-workspace' })
409
+ return
410
+ }
411
+ if (!existsSync(target.path)) {
412
+ sendJson(res, 200, { ok: false, error: 'workspace-missing', path: target.path })
413
+ return
414
+ }
415
+ const command = openInFileManager(ctx, target.path)
416
+ ctx.logger.info(`net-speed-open: 打开工作区目录 ${target.path}(${target.source} / ${command})`)
417
+ sendJson(res, 200, { ok: true, path: target.path, title: target.title, source: target.source, command })
418
+ } catch (error) {
419
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) })
420
+ }
421
+ }
422
+ }), 'net-speed-open: /open-workspace')
423
+ }
@@ -0,0 +1,56 @@
1
+ # Opens a folder in Explorer and makes the window actually visible, then lets go.
2
+ #
3
+ # Why this exists: DSH starts plugin subprocesses hidden, so an explorer window spawned from
4
+ # here is created with visible = false - it exists (COM enumerates it) but the user cannot
5
+ # see it. Showing it once and handing it the foreground once is the whole job.
6
+ #
7
+ # Deliberately NOT done here: HWND_TOPMOST, and a loop of SetForegroundWindow calls. Those
8
+ # keep the window glued on top for seconds and fight the app the user was just using, which
9
+ # feels like being hijacked ("did I just get a virus?"). If the other window takes the focus
10
+ # back, the folder window simply stays in the taskbar like any normal window.
11
+ # A synthetic ALT tap was also tried and made things worse - do not add keybd_event back.
12
+ #
13
+ # ASCII only on purpose: PowerShell 5.1 reads script files as ANSI unless they carry a BOM.
14
+ param([Parameter(Mandatory = $true)][string]$Path)
15
+
16
+ $ErrorActionPreference = 'Stop'
17
+ Add-Type -Path (Join-Path $PSScriptRoot 'focus.cs')
18
+
19
+ $leaf = Split-Path -Leaf $Path
20
+ $shell = New-Object -ComObject Shell.Application
21
+ $shell.Explore($Path)
22
+ Start-Sleep -Milliseconds 1200
23
+
24
+ $script:target = [IntPtr]::Zero
25
+ $callback = [FolderFocus+EnumProc] {
26
+ param($hwnd, $lparam)
27
+ $cls = New-Object System.Text.StringBuilder 256
28
+ [void][FolderFocus]::GetClassName($hwnd, $cls, 256)
29
+ if ($cls.ToString() -ne 'CabinetWClass') { return $true }
30
+ $title = New-Object System.Text.StringBuilder 512
31
+ [void][FolderFocus]::GetWindowText($hwnd, $title, 512)
32
+ if ($title.ToString() -like "$leaf*") {
33
+ $script:target = $hwnd
34
+ return $false
35
+ }
36
+ return $true
37
+ }
38
+ [void][FolderFocus]::EnumWindows($callback, [IntPtr]::Zero)
39
+
40
+ if ($script:target -eq [IntPtr]::Zero) {
41
+ Write-Output "no-window:$leaf"
42
+ exit 0
43
+ }
44
+
45
+ # One shot: make it visible, bring it forward, give it the foreground. Then stop.
46
+ $foreground = [FolderFocus]::GetForegroundWindow()
47
+ $foregroundThread = [FolderFocus]::GetWindowThreadProcessId($foreground, [IntPtr]::Zero)
48
+ $myThread = [FolderFocus]::GetCurrentThreadId()
49
+ [void][FolderFocus]::AttachThreadInput($myThread, $foregroundThread, $true)
50
+ [void][FolderFocus]::ShowWindow($script:target, 5) # SW_SHOW
51
+ [void][FolderFocus]::ShowWindow($script:target, 9) # SW_RESTORE
52
+ [void][FolderFocus]::BringWindowToTop($script:target)
53
+ $focused = [FolderFocus]::SetForegroundWindow($script:target)
54
+ [void][FolderFocus]::AttachThreadInput($myThread, $foregroundThread, $false)
55
+
56
+ Write-Output ("shown:{0}:{1}" -f $leaf, $focused)
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "dsh-users-open-source-workspace",
3
+ "version": "0.1.0",
4
+ "description": "dsh-users-open-source 的伴生包:受理 POST /dsh-users-open-source/open-workspace,在系统文件管理器里打开用户当前工作区目录(设置页「开源用户」里那颗「云端备份」按钮)。目录只从 DSH 自己的工作区账本里取,客户端只能递 sessionId;Windows 上用包内 PowerShell 脚本打开并显形(DSH 以隐藏方式启动子进程,直接 spawn explorer.exe 只会得到一个 visible=false 的隐身窗口)。它不是独立 bundle —— loader entry 由 dsh-users-open-source 的 cordis.patch.yml 插入。",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "workspace",
10
+ "open-folder",
11
+ "explorer"
12
+ ],
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "exports": {
16
+ ".": {
17
+ "default": "./lib/index.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "lib",
23
+ "tests",
24
+ "README.md"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22.13.0"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/KAIROSLLL/dsh-users-open-source.git",
35
+ "directory": "packages/dsh-users-open-source-workspace"
36
+ },
37
+ "homepage": "https://github.com/KAIROSLLL/dsh-users-open-source#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/KAIROSLLL/dsh-users-open-source/issues"
40
+ },
41
+ "license": "MIT",
42
+ "scripts": {
43
+ "test": "node tests/smoke.mjs"
44
+ }
45
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * dsh-users-open-source-workspace 冒烟测试:不需要 DSH 运行时,node 直接跑。
3
+ *
4
+ * 覆盖:路由注册、sessionId 命中工作区、以及三条"当前工作区"兜底线索
5
+ * (会话日志分桶 → 投影缓存 cwd → updatedAt 最新)与各种失败分支。
6
+ *
7
+ * 用法:node tests/smoke.mjs
8
+ */
9
+ import { strict as assert } from 'node:assert'
10
+ import { createServer } from 'node:http'
11
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+
15
+ /** 测试期间把 DSH home 指到临时目录,别污染真身。 */
16
+ process.env.DSH_HOME = join(tmpdir(), 'dsh-users-open-source-ws-smoke')
17
+ /** 只解析、不真的弹资源管理器。 */
18
+ process.env.DSH_NET_SPEED_DRY_OPEN = '1'
19
+
20
+ const mod = await import(new URL('../lib/index.js', import.meta.url).href)
21
+
22
+ /** 收集失败,最后统一决定退出码。 */
23
+ const failures = []
24
+
25
+ /**
26
+ * 跑一个测试用例,失败不中断。
27
+ * @param {string} title - 用例名。
28
+ * @param {() => Promise<void>|void} body - 用例体。
29
+ * @returns {Promise<void>} 结束。
30
+ */
31
+ async function test(title, body) {
32
+ try {
33
+ await body()
34
+ console.log(` ✓ ${title}`)
35
+ } catch (error) {
36
+ failures.push(`${title}: ${String((error && error.stack) || error)}`)
37
+ console.log(` ✗ ${title}`)
38
+ }
39
+ }
40
+
41
+ /** 路由表:path → handler。 */
42
+ const routes = new Map()
43
+ /** effect 清理函数。 */
44
+ const disposers = []
45
+ const logs = []
46
+ /** 假的宿主服务表。 */
47
+ const services = {}
48
+
49
+ const ctx = {
50
+ get: (name) => services[name],
51
+ logger: {
52
+ info: (message) => logs.push(`[info] ${message}`),
53
+ warn: (message) => logs.push(`[warn] ${message}`)
54
+ },
55
+ effect: (factory) => {
56
+ const dispose = factory()
57
+ if (typeof dispose === 'function') disposers.push(dispose)
58
+ return () => {}
59
+ },
60
+ webServer: {
61
+ register: (route) => {
62
+ routes.set(route.path, route.handler)
63
+ return () => routes.delete(route.path)
64
+ }
65
+ },
66
+ subprocess: {
67
+ spawn: () => ({ done: Promise.resolve({ exitCode: 0, signal: null }) })
68
+ }
69
+ }
70
+
71
+ const home = process.env.DSH_HOME
72
+
73
+ await test('模块导出 name / inject / apply', () => {
74
+ assert.equal(mod.name, 'dsh-users-open-source-workspace')
75
+ assert.deepEqual(Array.from(mod.inject), ['webServer', 'subprocess'])
76
+ assert.equal(typeof mod.apply, 'function')
77
+ })
78
+
79
+ await test('apply() 挂上 /dsh-users-open-source/open-workspace', () => {
80
+ mod.apply(ctx)
81
+ assert.deepEqual(Array.from(routes.keys()), ['/dsh-users-open-source/open-workspace'])
82
+ })
83
+
84
+ /** 把路由挂到一个真 HTTP 口上,方便用 fetch 打。 */
85
+ const bridge = createServer((req, res) => {
86
+ const url = new URL(typeof req.url === 'string' ? req.url : '/', 'http://127.0.0.1')
87
+ const handler = routes.get(url.pathname)
88
+ if (handler === undefined) {
89
+ res.writeHead(404, { 'content-type': 'text/plain' })
90
+ res.end('no route')
91
+ return
92
+ }
93
+ Promise.resolve(handler(req, res)).catch(() => {
94
+ try {
95
+ res.destroy()
96
+ } catch {
97
+ /* 忽略 */
98
+ }
99
+ })
100
+ })
101
+ await new Promise((resolve) => bridge.listen(0, '127.0.0.1', resolve))
102
+ const base = `http://127.0.0.1:${bridge.address().port}`
103
+
104
+ /**
105
+ * 打一个接口。
106
+ * @param {string} path - 路径。
107
+ * @param {object} [init] - fetch 选项。
108
+ * @returns {Promise<{status:number, body:any}>} 状态码与 JSON。
109
+ */
110
+ async function call(path, init) {
111
+ const res = await fetch(base + path, init)
112
+ let body = null
113
+ try {
114
+ body = await res.json()
115
+ } catch {
116
+ body = null
117
+ }
118
+ return { status: res.status, body }
119
+ }
120
+
121
+ /**
122
+ * 发一个 JSON POST。
123
+ * @param {string} path - 路径。
124
+ * @param {object} payload - 请求体。
125
+ * @returns {Promise<{status:number, body:any}>} 结果。
126
+ */
127
+ function open(payload) {
128
+ return call('/dsh-users-open-source/open-workspace', {
129
+ method: 'POST',
130
+ headers: { 'content-type': 'application/json' },
131
+ body: JSON.stringify(payload ?? {})
132
+ })
133
+ }
134
+
135
+ /** 一个"真实存在"的工作区目录。 */
136
+ const wsDir = join(tmpdir(), 'dsh-ns-ws-a')
137
+ /** 另一个"真实存在"的工作区目录。 */
138
+ const otherDir = join(tmpdir(), 'dsh-ns-ws-b')
139
+
140
+ await test('sessionId 命中工作区(最准的一条)', async () => {
141
+ await mkdir(wsDir, { recursive: true })
142
+ await mkdir(otherDir, { recursive: true })
143
+ services.workspaceRegistry = {
144
+ list: () => [
145
+ { id: 'w-a', path: wsDir, title: 'A', sessionIds: ['session-a'], updatedAt: '2026-01-01T00:00:00.000Z' },
146
+ { id: 'w-b', path: otherDir, title: 'B', sessionIds: ['session-b'], updatedAt: '2026-01-02T00:00:00.000Z' }
147
+ ]
148
+ }
149
+ const result = await open({ sessionId: 'session-a' })
150
+ assert.equal(result.status, 200)
151
+ assert.equal(result.body.ok, true, JSON.stringify(result.body))
152
+ assert.equal(result.body.path, wsDir)
153
+ assert.equal(result.body.source, 'session')
154
+ })
155
+
156
+ await test('线索 1:会话日志分桶(客户端没给 id 时)', async () => {
157
+ const bucket = `--${wsDir.replace(/[:\\/]/g, '-')}--`
158
+ const sessionDir = join(home, 'sessions', bucket, 'session-recent')
159
+ await mkdir(sessionDir, { recursive: true })
160
+ await writeFile(join(sessionDir, 'session.v4.jsonl.zstd'), 'x')
161
+ const result = await open({ sessionId: 'session-unknown' })
162
+ assert.equal(result.body.ok, true, JSON.stringify(result.body))
163
+ assert.equal(result.body.path, wsDir)
164
+ assert.equal(result.body.source, 'recent')
165
+ await rm(join(home, 'sessions'), { recursive: true, force: true })
166
+ })
167
+
168
+ await test('线索 2:会话投影缓存里的 cwd', async () => {
169
+ const cacheDir = join(home, 'storages', 'session_projcache', 'sessions')
170
+ await mkdir(cacheDir, { recursive: true })
171
+ await writeFile(
172
+ join(cacheDir, 'session-x.json'),
173
+ JSON.stringify({ record: { header: { cwd: otherDir }, rows: {} } })
174
+ )
175
+ const result = await open({ sessionId: 'session-unknown' })
176
+ assert.equal(result.body.ok, true, JSON.stringify(result.body))
177
+ assert.equal(result.body.path, otherDir)
178
+ assert.equal(result.body.source, 'recent')
179
+ await rm(join(home, 'storages'), { recursive: true, force: true })
180
+ })
181
+
182
+ await test('线索 3:updatedAt 最新的工作区', async () => {
183
+ const result = await open({ sessionId: 'session-unknown' })
184
+ assert.equal(result.body.ok, true, JSON.stringify(result.body))
185
+ assert.equal(result.body.path, otherDir, 'updatedAt 更新的是 B')
186
+ assert.equal(result.body.source, 'recent')
187
+ })
188
+
189
+ await test('三条线索都落空时退到第一个工作区', async () => {
190
+ services.workspaceRegistry = {
191
+ list: () => [
192
+ { id: 'w-a', path: wsDir, title: 'A', sessionIds: [] },
193
+ { id: 'w-b', path: otherDir, title: 'B', sessionIds: [] }
194
+ ]
195
+ }
196
+ const result = await open({})
197
+ assert.equal(result.body.path, wsDir)
198
+ assert.equal(result.body.source, 'first')
199
+ })
200
+
201
+ await test('工作区目录不在了 → workspace-missing', async () => {
202
+ services.workspaceRegistry = {
203
+ list: () => [{ id: 'w-x', path: join(tmpdir(), 'dsh-ns-definitely-missing'), title: 'X', sessionIds: [] }]
204
+ }
205
+ const result = await open({})
206
+ assert.equal(result.body.ok, false)
207
+ assert.equal(result.body.error, 'workspace-missing')
208
+ })
209
+
210
+ await test('一个工作区都没有 → no-workspace;GET → 405', async () => {
211
+ services.workspaceRegistry = { list: () => [] }
212
+ const none = await open({})
213
+ assert.equal(none.body.ok, false)
214
+ assert.equal(none.body.error, 'no-workspace')
215
+
216
+ const wrongMethod = await call('/dsh-users-open-source/open-workspace')
217
+ assert.equal(wrongMethod.status, 405)
218
+ })
219
+
220
+ // 收尾
221
+ for (const dispose of disposers) {
222
+ try {
223
+ dispose()
224
+ } catch {
225
+ /* 忽略 */
226
+ }
227
+ }
228
+ bridge.close()
229
+ await rm(home, { recursive: true, force: true })
230
+
231
+ console.log('')
232
+ if (failures.length > 0) {
233
+ console.error(`✗ ${failures.length} 个用例失败:`)
234
+ for (const failure of failures) console.error(`\n--- ${failure}`)
235
+ process.exit(1)
236
+ }
237
+ console.log(`✓ 全部用例通过(宿主日志 ${logs.length} 条)`)
238
+ for (const line of logs) console.log(` ${line}`)