dsh-explorer-open 0.0.0-stage → 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 dsh-explorer-open contributors
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 CHANGED
@@ -1,3 +1,138 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # dsh-explorer-open
2
+
3
+ 在 Windows 资源管理器的右键菜单里加一条 **「通过 dsh 打开」**:点一下就在 [DeepSeek Harness](https://github.com/deepseek-ai) 里打开这个文件夹(没有工作区就新建),并开一个新会话。
4
+
5
+ Win11 的**新式简洁菜单**和**旧式完整菜单**里都有这一条,都带图标。
6
+
7
+ - 仓库:<https://github.com/ame-code/dsh-explorer-open>
8
+ - npm:`dsh-explorer-open`
9
+
10
+ ## 功能
11
+
12
+ | 在哪儿右键 | 打开的文件夹 | 放进新会话的草稿 |
13
+ | --- | --- | --- |
14
+ | 文件夹空白处 | 当前文件夹 | 无 |
15
+ | 文件夹图标上 | 这个文件夹 | 无 |
16
+ | 任意文件上 | 文件所在的文件夹 | `@文件名` 引用(工作区之外的路径不引用) |
17
+
18
+ - 每次都**新建会话**并切过去;工作区按完整路径复用,不会重复创建。
19
+ - dsh 没开时会先把它拉起来,请求排队等页面连上再执行。
20
+ - 插件停用/卸载时会**自己拆掉菜单**(旧式注册表键 + Win11 新式 MSIX 包),不留垃圾。
21
+
22
+ ## 状态
23
+
24
+ 当前 **0.1.x**:功能可用,但接口(`/api/plugins/...` 路径、`~/.dsh/dsh-explorer-open/` 状态目录、`DSH_EXPLORER_OPEN_*` 环境变量)和安装方式都还可能变,破坏性改动不保证向后兼容。
25
+
26
+ 原生组件(`win11/`)有独立的版本号,见 `native/version.txt`;JS 侧版本见 `package.json`。
27
+
28
+ ## 安装
29
+
30
+ ### 从 npm 安装(推荐)
31
+
32
+ 在 dsh 的插件设置里安装 `dsh-explorer-open`,或让 agent 执行安装(bundle 安装入口接受 npm 包名)。
33
+
34
+ 首次启用时会自动装 Win11 新式菜单所需的 MSIX 包。因为是自签证书,包里没有受信的根,Windows 会拒绝安装并给出:
35
+
36
+ ```
37
+ Deployment failed with HRESULT: 0x800B0109
38
+ ```
39
+
40
+ 这时以管理员身份运行一次插件目录下的 `native/trust-cert.bat`(把自签证书 `dsh-open-with.cer` 导入 `LocalMachine\TrustedPeople`),然后重启 dsh 即可。只需要做一次。
41
+
42
+ > 只想要旧式菜单、不想动证书:设环境变量 `DSH_EXPLORER_OPEN_NO_MSIX=1` 再启动 dsh,插件就不会去装 MSIX 包。
43
+
44
+ ### 从源码/本地目录安装
45
+
46
+ ```powershell
47
+ # 仓库本身就是插件包(package.json 在根目录)
48
+ plugin_manager install_bundle E:\work\tools\dsh-open-with
49
+ ```
50
+
51
+ 改过代码后要重启 dsh 才会重新加载宿主侧插件;客户端 bundle 需要刷新页面。
52
+
53
+ ## 工作原理
54
+
55
+ ```
56
+ 资源管理器右键
57
+ ├─ 旧式菜单:HKCU\Software\Classes\{Directory\Background, Directory, *}\shell\OpenWithDsh
58
+ │ └─ powershell -File launch.ps1 "%V" (无窗口,写入待办后退出)
59
+ └─ 新式菜单:MSIX 包身份 + 原生 IExplorerCommand
60
+ └─ 同一个 launch.ps1
61
+ ↓
62
+ %USERPROFILE%\.dsh\dsh-explorer-open\pending.json {folder, file?, token, at}
63
+ ↓ 宿主侧插件每 300ms 扫描
64
+ GET /api/plugins/dsh-explorer-open/request (带 x-dsh-explorer-open 头)
65
+ ↓ 页面侧插件每 1s 轮询
66
+ workspaces.create → sessions.create → uiWorkspace.openSession
67
+ ↓ 文件右键时
68
+ conversation.input.requestDraftInitialization(binding, { prompt: '@文件', clearPreviousDraft: true })
69
+ ```
70
+
71
+ - 启动器用 PowerShell 5.1 写 UTF-8(**无 BOM**)、先写临时文件再改名,页面不会读到半个 JSON。
72
+ - `/api/...` 路由要求自定义请求头,跨站页面带不上它(会先触发预检),因此别的网页没法领走排队中的请求。
73
+ - dsh 可执行文件位置由插件写进 `config.json`,启动器按 config → `DSH_EXPLORER_OPEN_EXE` → 常见安装路径的顺序查找。
74
+
75
+ ## win11/ 原生组件
76
+
77
+ Win11 新式菜单不接受「注册表 + exe」的旧式扩展,必须是一个**有包身份、已签名、且 DLL 内嵌了 msix 身份清单**的 COM 组件。`win11/` 就是这个组件:
78
+
79
+ ```
80
+ win11/
81
+ ├─ src/dsh_open_with.cpp IExplorerCommand 实现(GetTitle/GetIcon/GetState/Invoke)
82
+ ├─ src/stub.cpp 满足清单的占位 exe(Windows.FullTrustApplication 入口,永不被启动)
83
+ ├─ src/test-host.cpp 不经过资源管理器的自测宿主
84
+ ├─ src/msix.manifest DLL 内嵌的 msix 身份(publisher/packageName/applicationId)
85
+ ├─ src/msix.rc 把上面那份清单编进 PE 资源
86
+ ├─ assets/dsh.ico 菜单图标
87
+ ├─ package/AppxManifest.xml MSIX 清单:comServer + fileExplorerContextMenus
88
+ ├─ build.sh 构建 → 签名 → 打包 → 同步到 ../native/
89
+ ├─ install-msix.ps1 / uninstall-msix.ps1
90
+ └─ trust-cert.ps1 / trust-cert.bat 把自签证书导入受信存储
91
+ ```
92
+
93
+ 重新构建(需要 MinGW-w64、`windres`、Windows SDK BuildTools 的 `makeappx`/`signtool`,以及 `Cert:\CurrentUser\My` 里的 `CN=DshOpenWith` 证书):
94
+
95
+ ```bash
96
+ cd win11
97
+ bash build.sh
98
+ ```
99
+
100
+ `build.sh` 会把签名好的 `DshOpenWith.msix`、`dsh-open-with.cer` 和 `version.txt` 复制到 `../native/`,插件启用时按 `version.txt` 决定要不要重装。
101
+
102
+ ### 踩过的坑(都已在代码里修掉)
103
+
104
+ 1. **清单里的 GUID 不能带花括号**,否则 `Add-AppxPackage` 报 `0x80080204 / 0xC00CE169`。
105
+ 2. **DLL 必须内嵌 msix 身份清单**,否则资源管理器根本不会加载这个 COM 类(表现为"装好了但菜单里没有")。
106
+ 3. DLL 必须 `-static` 链接:包目录里没有 `libwinpthread-1.dll`。
107
+ 4. `GetState` 不要依赖选中项解析结果:新式菜单取「文件夹空白处」时 `IShellItemArray` 是空的,返回 `ECS_HIDDEN` 会把菜单项藏起来。恒返回 `ECS_ENABLED`,路径解析放到 `Invoke`。
108
+ 5. AppX 代理进程里 `GetModuleFileNameW(NULL)` 返回的是代理进程的路径,图标要按 `DllMain` 保存的 `hinst` 取自身目录。
109
+ 6. 自签证书必须进 `LocalMachine\TrustedPeople`;`CurrentUser\Root` / `CurrentUser\TrustedPeople` 都不够。
110
+
111
+ ## 开发
112
+
113
+ ```bash
114
+ # 宿主侧:注册表、待办扫描、HTTP 路由、启用/停用清理
115
+ # USERPROFILE 指向一个空目录(要用 Windows 路径写法),DSH_EXPLORER_OPEN_TEST_HOME=1 是防误跑的开关
116
+ USERPROFILE='E:\tmp\dsh-explorer-open-home' DSH_EXPLORER_OPEN_TEST_HOME=1 \
117
+ DSH_EXPLORER_OPEN_REG_BASE='HKCU\Software\Classes\__dsh_explorer_open_test' \
118
+ DSH_EXPLORER_OPEN_NO_MSIX=1 node test/host-test.mjs
119
+
120
+ # 页面侧:调用顺序、参数、@引用拼装、失败回退
121
+ node test/client-test.mjs
122
+ ```
123
+
124
+ 两个脚本都用假的 ctx / fetch,不会碰真实的菜单和 MSIX 包。
125
+
126
+ - `HANDOFF.md`:开发笔记(决策记录、踩坑、待办),中文。
127
+ - `win11/build/` 与 `.probe/` 是构建产物和本地临时目录,不纳入版本库。
128
+
129
+ ## 已知限制
130
+
131
+ - 只在 Windows 上工作(注册表 + MSIX + 资源管理器)。
132
+ - `*` 键会让这一条出现在**所有**文件类型上,包括没有关联程序的。
133
+ - 以管理员权限运行的资源管理器不会显示用户级(HKCU)菜单项。
134
+ - 新式菜单需要证书受信;分发第三方时应该用正式代码签名证书,而不是自签。
135
+
136
+ ## License
137
+
138
+ MIT
package/assets/dsh.ico ADDED
Binary file
package/client.js ADDED
@@ -0,0 +1,143 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-explorer-open',
3
+ factory(require) {
4
+ const REQUEST_URL = '/api/plugins/dsh-explorer-open/request'
5
+ // 自定义请求头:跨站页面无法在不触发预检的情况下带上它,
6
+ // 可以挡住别的网页偷偷 GET 掉排队中的请求。
7
+ const REQUEST_HEADER = 'x-dsh-explorer-open'
8
+ const POLL_MS = 1000
9
+
10
+ return {
11
+ inject: ['uiWorkspace', 'sessions', 'workspaces'],
12
+ apply(ctx) {
13
+ const log = (message, error) => {
14
+ if (error === undefined) console.info(`[dsh-explorer-open] ${message}`)
15
+ else console.warn(`[dsh-explorer-open] ${message}`, error)
16
+ }
17
+
18
+ /** 打开工作区(已存在则复用),在其中新建一个会话,再切过去。 */
19
+ async function handle(folder, file) {
20
+ // create 对同一路径是幂等的:有就返回已有的,没有才新建
21
+ const workspace = await ctx.workspaces.create({ path: folder })
22
+ const workspaceId = workspace.workspaceId
23
+
24
+ let sessionId
25
+ try {
26
+ // 不关心有没有旧会话:每次都新建一个
27
+ sessionId = await ctx.sessions.create({ workspaceId, cwd: workspace.path })
28
+ } catch (error) {
29
+ log(`新建会话失败,改为打开工作区: ${folder}`, error)
30
+ await ctx.uiWorkspace.openWorkspace(workspaceId)
31
+ return
32
+ }
33
+
34
+ try {
35
+ ctx.uiWorkspace.openSession(sessionId)
36
+ } catch (error) {
37
+ log(`切换到新会话失败,改为打开工作区: ${folder}`, error)
38
+ await ctx.uiWorkspace.openWorkspace(workspaceId)
39
+ return
40
+ }
41
+ log(`已打开: ${folder} → 会话 ${sessionId}`)
42
+
43
+ if (file !== undefined) await seedFileReference(workspace.path, sessionId, file)
44
+ }
45
+
46
+ /**
47
+ * 把被点中的文件写成草稿里的 @引用。
48
+ * 引用按会话 cwd 解析,所以只接受位于工作区之内的文件;含空格的路径用 @"..." 形式。
49
+ */
50
+ function relativeMention(cwd, file) {
51
+ const trim = (path) => path.replace(/[\\/]+$/u, '')
52
+ const base = trim(cwd)
53
+ const target = trim(file)
54
+ const lower = (path) => path.toLowerCase()
55
+ if (lower(target) === lower(base)) return undefined
56
+ const prefix = lower(target).startsWith(`${lower(base)}\\`) || lower(target).startsWith(`${lower(base)}/`)
57
+ if (!prefix) return undefined
58
+ const relative = target.slice(base.length + 1)
59
+ return `${/\s/u.test(relative) ? `@"${relative}"` : `@${relative}`} `
60
+ }
61
+
62
+ /** 新会话刚建好时,binding 可能还没就绪,短暂重试几次再写草稿。 */
63
+ async function seedFileReference(cwd, sessionId, file) {
64
+ const mention = relativeMention(cwd, file)
65
+ if (mention === undefined) {
66
+ log(`文件不在工作区内,跳过引用: ${file}`)
67
+ return
68
+ }
69
+
70
+ let input
71
+ try {
72
+ input = ctx.get('conversation')?.input
73
+ } catch {
74
+ input = undefined
75
+ }
76
+ if (input?.requestDraftInitialization === undefined) {
77
+ log(`当前界面不支持预置草稿,跳过引用: ${file}`)
78
+ return
79
+ }
80
+
81
+ for (let attempt = 0; attempt < 20; attempt += 1) {
82
+ const binding = ctx.sessions.binding?.(sessionId)
83
+ if (binding !== undefined) {
84
+ try {
85
+ const result = input.requestDraftInitialization(binding, {
86
+ prompt: mention,
87
+ clearPreviousDraft: true,
88
+ })
89
+ if (result !== 'blocked') {
90
+ log(`已在草稿中引用: ${mention.trim()}`)
91
+ return
92
+ }
93
+ } catch (error) {
94
+ log(`写入草稿失败: ${file}`, error)
95
+ return
96
+ }
97
+ }
98
+ await new Promise((resolve) => setTimeout(resolve, 100))
99
+ }
100
+ log(`草稿未就绪,跳过引用: ${file}`)
101
+ }
102
+
103
+ async function poll() {
104
+ let data
105
+ try {
106
+ const res = await fetch(REQUEST_URL, { cache: 'no-store', headers: { [REQUEST_HEADER]: '1' } })
107
+ if (!res.ok) return
108
+ data = await res.json()
109
+ } catch {
110
+ return
111
+ }
112
+ if (data?.status !== 'open' || !Array.isArray(data.requests)) return
113
+ for (const request of data.requests) {
114
+ if (request === null || typeof request.folder !== 'string') continue
115
+ const file = typeof request.file === 'string' ? request.file : undefined
116
+ try {
117
+ await handle(request.folder, file)
118
+ } catch (error) {
119
+ log(`打开失败: ${request.folder}`, error)
120
+ }
121
+ }
122
+ }
123
+
124
+ let busy = false
125
+ async function tick() {
126
+ if (busy) return
127
+ busy = true
128
+ try {
129
+ await poll()
130
+ } finally {
131
+ busy = false
132
+ }
133
+ }
134
+
135
+ ctx.effect(() => {
136
+ void tick()
137
+ const timer = setInterval(() => void tick(), POLL_MS)
138
+ return () => clearInterval(timer)
139
+ })
140
+ },
141
+ }
142
+ },
143
+ })
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: dsh-explorer-open
3
+ name: 'dsh-explorer-open'
4
+ config: {}
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect x="4" y="10" width="56" height="44" rx="8" fill="#2f6bff"/>
3
+ <path d="M14 26l10 6-10 6z" fill="#fff"/>
4
+ <rect x="30" y="36" width="18" height="4" rx="2" fill="#fff"/>
5
+ </svg>
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import { apply } from './lib/index.js'
2
+
3
+ export { apply }
4
+ export const inject = []
package/lib/index.js ADDED
@@ -0,0 +1,220 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { installLegacy, isLegacyInstalled, refreshExplorer, removeLegacy } from './registry.js'
6
+ import { installPackage, removePackage } from './win11.js'
7
+
8
+ /** 启动器写、本插件读的待办文件。 */
9
+ const PENDING_DIR = join(homedir(), '.dsh', 'dsh-explorer-open')
10
+ const PENDING_FILE = join(PENDING_DIR, 'pending.json')
11
+
12
+ /** 页面询问「有没有新的右键请求」的接口。 */
13
+ export const REQUEST_PATH = '/api/plugins/dsh-explorer-open/request'
14
+
15
+ /** 页面轮询时必须带上的请求头,见 client.js。 */
16
+ const REQUEST_HEADER = 'x-dsh-explorer-open'
17
+
18
+ /** 超过这个时间的请求视为过期,不再执行。 */
19
+ const REQUEST_TTL_MS = 120_000
20
+
21
+ /** 同一路径连续请求(右键双击、菜单被重复触发)的合并窗口。 */
22
+ const DUPLICATE_WINDOW_MS = 1_500
23
+
24
+ /** 扫描待办文件的间隔。 */
25
+ const SCAN_MS = 300
26
+
27
+ /** 插件自带的启动器与图标;安装时复制到 PENDING_DIR,菜单指向那两份副本。 */
28
+ const PLUGIN_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')
29
+ const LAUNCHER_SOURCE = join(PLUGIN_DIR, 'lib', 'launch.ps1')
30
+ const LAUNCHER_PATH = join(PENDING_DIR, 'launch.ps1')
31
+ const ICON_SOURCE = join(PLUGIN_DIR, 'assets', 'dsh.ico')
32
+ const ICON_PATH = join(PENDING_DIR, 'dsh.ico')
33
+ const CONFIG_FILE = join(PENDING_DIR, 'config.json')
34
+
35
+ /**
36
+ * 把启动器和图标复制到固定位置,并写下 dsh 可执行文件的位置。
37
+ * 菜单里写死的是这个固定位置的路径,这样插件目录被移动/删除后菜单仍然可用,
38
+ * 也让注册表里不会出现一个指向开发目录的路径。
39
+ * config.json 让启动器知道该唤起哪个 exe,不必猜安装目录。
40
+ */
41
+ function ensureRuntime() {
42
+ mkdirSync(PENDING_DIR, { recursive: true })
43
+ for (const [source, target] of [
44
+ [LAUNCHER_SOURCE, LAUNCHER_PATH],
45
+ [ICON_SOURCE, ICON_PATH],
46
+ ]) {
47
+ const bytes = readFileSync(source)
48
+ const current = existsSync(target) ? readFileSync(target) : undefined
49
+ if (current === undefined || !current.equals(bytes)) writeFileSync(target, bytes)
50
+ }
51
+ const config = `${JSON.stringify({ exe: process.execPath }, null, 2)}\n`
52
+ if (!existsSync(CONFIG_FILE) || readFileSync(CONFIG_FILE, 'utf8') !== config) {
53
+ writeFileSync(CONFIG_FILE, config)
54
+ }
55
+ return { launcher: LAUNCHER_PATH, icon: ICON_PATH }
56
+ }
57
+
58
+ function isDirectory(path) {
59
+ try {
60
+ return statSync(path).isDirectory()
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ function isFile(path) {
67
+ try {
68
+ return statSync(path).isFile()
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
74
+ function removePending() {
75
+ try {
76
+ rmSync(PENDING_FILE, { force: true })
77
+ } catch {
78
+ // 下次扫描会再试
79
+ }
80
+ }
81
+
82
+ function readPending() {
83
+ if (!existsSync(PENDING_FILE)) return undefined
84
+ try {
85
+ const payload = JSON.parse(readFileSync(PENDING_FILE, 'utf8'))
86
+ if (typeof payload?.folder !== 'string' || typeof payload?.at !== 'number') {
87
+ removePending()
88
+ return undefined
89
+ }
90
+ if (payload.file !== undefined && typeof payload.file !== 'string') {
91
+ removePending()
92
+ return undefined
93
+ }
94
+ return payload
95
+ } catch {
96
+ // 可能正好写了一半,留着下次再读
97
+ return undefined
98
+ }
99
+ }
100
+
101
+ export function apply(ctx) {
102
+ /** 待页面取走的请求;同一时刻只保留最新的一条。 */
103
+ let queue = []
104
+
105
+ // 0. Win11 新式精简菜单:装上带包身份的原生扩展(旧式菜单见下一段)
106
+ // 设 DSH_EXPLORER_OPEN_NO_MSIX=1 可跳过(离线自测时用,免得卸载真实安装的包)
107
+ ctx.effect(() => {
108
+ if (process.env.DSH_EXPLORER_OPEN_NO_MSIX === '1') return
109
+ let disposed = false
110
+ void (async () => {
111
+ try {
112
+ const result = installPackage()
113
+ if (result.status === 'untrusted') {
114
+ ctx.logger?.warn?.(
115
+ `[dsh-explorer-open] 新式菜单未启用:签名证书尚未被信任,请以管理员身份运行 ${result.detail}`,
116
+ )
117
+ return
118
+ }
119
+ if (result.status === 'missing-msix' || result.status === 'failed') {
120
+ ctx.logger?.warn?.(`[dsh-explorer-open] 新式菜单未启用: ${result.detail}`)
121
+ return
122
+ }
123
+ ctx.logger?.info?.(`[dsh-explorer-open] 新式菜单已就绪 ${result.status} ${result.detail}`)
124
+ if (result.status === 'installed') await refreshExplorer()
125
+ } catch (error) {
126
+ if (!disposed) ctx.logger?.warn?.(`[dsh-explorer-open] 安装新式菜单失败: ${error}`)
127
+ }
128
+ })()
129
+
130
+ return () => {
131
+ disposed = true
132
+ try {
133
+ removePackage()
134
+ } catch (error) {
135
+ ctx.logger?.warn?.(`[dsh-explorer-open] 卸载新式菜单失败: ${error}`)
136
+ }
137
+ }
138
+ })
139
+
140
+ // 1. 注入右键菜单;插件停用时移除
141
+ ctx.effect(() => {
142
+ let disposed = false
143
+ void (async () => {
144
+ try {
145
+ const { launcher, icon } = ensureRuntime()
146
+ await installLegacy(launcher, icon)
147
+ const ok = await isLegacyInstalled()
148
+ ctx.logger?.info?.(`[dsh-explorer-open] 右键菜单已写入=${ok} (${launcher})`)
149
+ await refreshExplorer()
150
+ } catch (error) {
151
+ if (!disposed) ctx.logger?.warn?.(`[dsh-explorer-open] 写入右键菜单失败: ${error}`)
152
+ }
153
+ })()
154
+
155
+ return () => {
156
+ disposed = true
157
+ void removeLegacy()
158
+ .then(() => refreshExplorer())
159
+ .catch((error) => ctx.logger?.warn?.(`[dsh-explorer-open] 移除右键菜单失败: ${error}`))
160
+ }
161
+ })
162
+
163
+ // 2. 扫描启动器留下的待办文件,转成内存里的请求
164
+ ctx.effect(() => {
165
+ let lastKey
166
+ let lastAt = 0
167
+
168
+ const tick = () => {
169
+ const payload = readPending()
170
+ if (payload === undefined) return
171
+ removePending()
172
+
173
+ if (Date.now() - payload.at > REQUEST_TTL_MS) return
174
+ if (!isDirectory(payload.folder)) return
175
+ // 文件右键:文件必须还在,否则只打开它所在的文件夹
176
+ const file = payload.file !== undefined && isFile(payload.file) ? payload.file : undefined
177
+ const key = `${payload.folder}\u0000${file ?? ''}`
178
+ // 1.5 秒内对同一目标的重复请求只算一次
179
+ if (key === lastKey && Date.now() - lastAt < DUPLICATE_WINDOW_MS) return
180
+
181
+ lastKey = key
182
+ lastAt = Date.now()
183
+ // 只保留最新一条;页面每秒来取一次
184
+ const request = { folder: payload.folder, token: payload.token ?? '', at: payload.at }
185
+ if (file !== undefined) request.file = file
186
+ queue = [request]
187
+ ctx.logger?.info?.(`[dsh-explorer-open] 收到请求: ${file ?? payload.folder}`)
188
+ }
189
+
190
+ const timer = setInterval(tick, SCAN_MS)
191
+ return () => clearInterval(timer)
192
+ })
193
+
194
+ // 3. 页面轮询这个接口领取请求
195
+ ctx.inject(['webServer'], (webCtx) => {
196
+ webCtx.effect(() =>
197
+ webCtx.webServer.register({
198
+ kind: 'exact',
199
+ path: REQUEST_PATH,
200
+ resource: async (request) => {
201
+ // 只认页面自己发的请求:跨站页面带不上这个头(会先触发预检)
202
+ if (request?.headers?.get?.(REQUEST_HEADER) !== '1') {
203
+ return new Response(JSON.stringify({ error: 'forbidden' }), {
204
+ status: 403,
205
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
206
+ })
207
+ }
208
+ const now = Date.now()
209
+ const taken = queue.filter((item) => now - item.at <= REQUEST_TTL_MS)
210
+ queue = []
211
+ const reply = { status: taken.length === 0 ? 'idle' : 'open', requests: taken }
212
+ return new Response(JSON.stringify(reply), {
213
+ status: 200,
214
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
215
+ })
216
+ },
217
+ }),
218
+ )
219
+ })
220
+ }
package/lib/launch.ps1 ADDED
@@ -0,0 +1,68 @@
1
+ # Explorer context menu entry point.
2
+ # Kept pure ASCII on purpose: Windows PowerShell 5.1 decodes BOM-less .ps1
3
+ # files with the ANSI code page, so non-ASCII text would be mojibake.
4
+ param([Parameter(Position = 0)][string]$Target)
5
+
6
+ $ErrorActionPreference = 'SilentlyContinue'
7
+
8
+ # Normalise "%V": the shell may hand over a quoted path or a trailing separator.
9
+ $path = $Target
10
+ if ($null -ne $path) { $path = $path.Trim().Trim('"') }
11
+ if ($null -ne $path) { $path = $path.TrimEnd('\', '/') }
12
+
13
+ # A folder opens itself; a file opens the folder that contains it and is also
14
+ # handed to the page so the new session can start with a reference to it.
15
+ $folder = $null
16
+ $file = $null
17
+
18
+ if ($path) {
19
+ if ([System.IO.Directory]::Exists($path)) {
20
+ $folder = $path
21
+ }
22
+ elseif ([System.IO.File]::Exists($path)) {
23
+ $file = $path
24
+ $folder = [System.IO.Path]::GetDirectoryName($path)
25
+ }
26
+ }
27
+
28
+ if (-not $folder) { exit 0 }
29
+
30
+ $root = Join-Path $env:USERPROFILE '.dsh\dsh-explorer-open'
31
+ if (-not [System.IO.Directory]::Exists($root)) { [void][System.IO.Directory]::CreateDirectory($root) }
32
+
33
+ $token = [guid]::NewGuid().ToString('N')
34
+ $payload = [ordered]@{
35
+ folder = $folder
36
+ token = $token
37
+ at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
38
+ }
39
+ if ($file) { $payload.file = $file }
40
+
41
+ # Write to a unique temp file and rename, so the page never reads half a JSON.
42
+ $json = $payload | ConvertTo-Json -Compress
43
+ $temp = Join-Path $root ("pending.$token.tmp")
44
+ $final = Join-Path $root 'pending.json'
45
+ [System.IO.File]::WriteAllText($temp, $json, [System.Text.UTF8Encoding]::new($false))
46
+ Move-Item -LiteralPath $temp -Destination $final -Force
47
+
48
+ # Find the DeepSeek Harness executable: the plugin writes config.json with the
49
+ # real path, then an environment variable, then a few well-known install spots.
50
+ $exe = $null
51
+ $config = Join-Path $root 'config.json'
52
+ if ([System.IO.File]::Exists($config)) {
53
+ try { $exe = (Get-Content -LiteralPath $config -Raw | ConvertFrom-Json).exe } catch { $exe = $null }
54
+ }
55
+ if (-not $exe -and $env:DSH_EXPLORER_OPEN_EXE) { $exe = $env:DSH_EXPLORER_OPEN_EXE }
56
+
57
+ if (-not ($exe -and [System.IO.File]::Exists($exe))) {
58
+ $candidates = @(
59
+ (Join-Path $env:LOCALAPPDATA 'Programs\DeepSeek Harness\DeepSeek Harness.exe'),
60
+ (Join-Path $env:ProgramFiles 'DeepSeek Harness\DeepSeek Harness.exe'),
61
+ 'D:\DeepSeek Harness\DeepSeek Harness.exe'
62
+ )
63
+ foreach ($candidate in $candidates) {
64
+ if ($candidate -and [System.IO.File]::Exists($candidate)) { $exe = $candidate; break }
65
+ }
66
+ }
67
+
68
+ if ($exe -and [System.IO.File]::Exists($exe)) { Start-Process -FilePath $exe }
@@ -0,0 +1,120 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+
7
+ const run = promisify(execFile)
8
+
9
+ /** 右键菜单显示文本,用户要求严格保持这个措辞。 */
10
+ export const MENU_TEXT = '通过 dsh 打开'
11
+
12
+ /**
13
+ * 旧式(扩展)右键菜单的挂载点:
14
+ * Directory\Background —— 在文件夹空白处右键(%V 是当前文件夹);
15
+ * Directory —— 在文件夹图标上右键(%V 是被点中的文件夹);
16
+ * * —— 在任意文件上右键(%V 是该文件的完整路径,由启动器取所在目录)。
17
+ * Win11 的简洁菜单也是从这里读的,所以这几处同时覆盖新菜单和旧菜单。
18
+ */
19
+ /** 自测时可用环境变量指到一次性的假位置,避免动到真实菜单;默认就是真实的用户级 Classes。 */
20
+ const REG_BASE = process.env.DSH_EXPLORER_OPEN_REG_BASE || 'HKCU\\Software\\Classes'
21
+
22
+ const LEGACY_SHELL_KEYS = [
23
+ `${REG_BASE}\\Directory\\Background\\shell\\OpenWithDsh`,
24
+ `${REG_BASE}\\Directory\\shell\\OpenWithDsh`,
25
+ `${REG_BASE}\\*\\shell\\OpenWithDsh`,
26
+ ]
27
+
28
+ /** 旧式菜单里显示的图标。默认用插件自带的那份(安装时复制到固定位置)。 */
29
+ const FALLBACK_ICON_PATH = ''
30
+
31
+ const POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
32
+
33
+ async function reg(args) {
34
+ const { stdout } = await run('reg.exe', args, { windowsHide: true })
35
+ return stdout
36
+ }
37
+
38
+ async function keyExists(key) {
39
+ try {
40
+ await reg(['query', key])
41
+ return true
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 生成 shell 命令。
49
+ * 菜单项经 cmd.exe 转发到 PowerShell:-NoProfile 保证不受用户配置文件影响,
50
+ * -ExecutionPolicy Bypass 保证脚本不会被组策略拦住。
51
+ */
52
+ export function menuCommand(scriptPath) {
53
+ return `"${POWERSHELL}" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "${scriptPath}" "%V"`
54
+ }
55
+
56
+ /**
57
+ * 写入旧式右键菜单项。
58
+ * %V 在 Background 键下是当前文件夹路径,在 Directory 键下是被点中的文件夹路径,
59
+ * 在 * 键下是被点中的文件路径(启动器会取它所在的文件夹并一并记下这个文件)。
60
+ * iconPath 为空时只写文字,不写图标。
61
+ */
62
+ export async function installLegacy(launcherPath, iconPath = FALLBACK_ICON_PATH) {
63
+ const command = menuCommand(launcherPath)
64
+ for (const key of LEGACY_SHELL_KEYS) {
65
+ await reg(['add', key, '/ve', '/d', MENU_TEXT, '/f'])
66
+ if (iconPath) await reg(['add', key, '/v', 'Icon', '/d', iconPath, '/f'])
67
+ await reg(['add', `${key}\\command`, '/ve', '/d', command, '/f'])
68
+ }
69
+ }
70
+
71
+ /** 精确删除本插件写入的注册表键,不影响其他软件。 */
72
+ export async function removeLegacy() {
73
+ for (const key of LEGACY_SHELL_KEYS) {
74
+ try {
75
+ await reg(['delete', key, '/f'])
76
+ } catch {
77
+ // 键本来就不存在,视为已清理干净
78
+ }
79
+ }
80
+ }
81
+
82
+ /** 用于确认启用/关闭是否真的生效。 */
83
+ export async function isLegacyInstalled() {
84
+ const results = await Promise.all(LEGACY_SHELL_KEYS.map(keyExists))
85
+ return results.every(Boolean)
86
+ }
87
+
88
+ /** 更新资源管理器,让右键菜单的改动立刻可见(失败不影响功能,只是可能要等一会)。 */
89
+ export async function refreshExplorer() {
90
+ const script = [
91
+ 'Add-Type -Namespace GoW -Name Native -MemberDefinition \'[DllImport("shell32.dll")] public static extern void SHChangeNotify(int e, uint f, IntPtr i1, IntPtr i2);\';',
92
+ '[GoW.Native]::SHChangeNotify(0x8000000, 0x1000, [IntPtr]::Zero, [IntPtr]::Zero)',
93
+ ].join(' ')
94
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-explorer-open-'))
95
+ const file = join(dir, 'refresh.ps1')
96
+ try {
97
+ await writeFile(file, script, 'utf8')
98
+ await run(POWERSHELL, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', file], {
99
+ windowsHide: true,
100
+ timeout: 10_000,
101
+ })
102
+ } catch {
103
+ // 忽略:菜单项本身已经写好,资源管理器迟早会刷新
104
+ } finally {
105
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Windows 11 的新式简洁右键菜单没有公开的注册接口,系统固定从扩展菜单读取。
111
+ * 该键存在 = 用户手动关掉了新菜单、退回旧菜单。
112
+ */
113
+ export async function isWin11NewMenu() {
114
+ try {
115
+ await reg(['query', 'HKCU\\Software\\Classes\\CLSID\\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\\InprocServer32'])
116
+ return false
117
+ } catch {
118
+ return true
119
+ }
120
+ }
package/lib/win11.js ADDED
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ /**
7
+ * Windows 11 新式右键菜单只接受「带包身份(packaged identity)」的扩展。
8
+ * 我们用稀疏 MSIX 包把原生 COM 组件(IExplorerCommand 实现)注册进那个菜单。
9
+ * 包名与 native/AppxManifest.xml 里的 Identity/@Name 必须一致。
10
+ */
11
+ export const PACKAGE_NAME = 'DshOpenWith.Local'
12
+
13
+ const POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
14
+ const NATIVE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'native')
15
+ const MSIX_PATH = join(NATIVE_DIR, 'DshOpenWith.msix')
16
+ const VERSION_FILE = join(NATIVE_DIR, 'version.txt')
17
+
18
+ /** 证书不受信时 Add-AppxPackage 的报错码。 */
19
+ const UNTRUSTED_HRESULT = '0x800B0109'
20
+
21
+ function runPowerShell(script) {
22
+ const result = spawnSync(
23
+ POWERSHELL,
24
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script],
25
+ { encoding: 'utf8', windowsHide: true, timeout: 60_000 },
26
+ )
27
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
28
+ return { ok: result.status === 0, output }
29
+ }
30
+
31
+ /** 打包时写进 native/version.txt 的期望版本。 */
32
+ export function expectedVersion() {
33
+ try {
34
+ return readFileSync(VERSION_FILE, 'utf8').trim()
35
+ } catch {
36
+ return ''
37
+ }
38
+ }
39
+
40
+ /** 当前用户已安装的包版本;没装返回空串。 */
41
+ export function installedVersion() {
42
+ const { output } = runPowerShell(
43
+ `(Get-AppxPackage -Name '${PACKAGE_NAME}' | Select-Object -First 1).Version`,
44
+ )
45
+ return output.trim()
46
+ }
47
+
48
+ /**
49
+ * 安装/升级新式菜单用的 MSIX 包。
50
+ * 返回 { status, detail },status 取值:missing-msix / current / installed / untrusted / failed。
51
+ */
52
+ export function installPackage() {
53
+ if (!existsSync(MSIX_PATH)) return { status: 'missing-msix', detail: MSIX_PATH }
54
+
55
+ const wanted = expectedVersion()
56
+ const installed = installedVersion()
57
+ if (installed !== '' && (wanted === '' || installed === wanted)) {
58
+ return { status: 'current', detail: installed }
59
+ }
60
+
61
+ const { output } = runPowerShell(`Add-AppxPackage -Path '${MSIX_PATH}'`)
62
+ if (output.includes(UNTRUSTED_HRESULT)) {
63
+ return { status: 'untrusted', detail: join(NATIVE_DIR, 'trust-cert.bat') }
64
+ }
65
+
66
+ const now = installedVersion()
67
+ if (now !== '' && (wanted === '' || now === wanted)) return { status: 'installed', detail: now }
68
+ return { status: 'failed', detail: output || `status=${now || 'none'}` }
69
+ }
70
+
71
+ /** 卸载新式菜单用的 MSIX 包;返回是否已确认移除。 */
72
+ export function removePackage() {
73
+ if (installedVersion() === '') return true
74
+ runPowerShell(`Get-AppxPackage -Name '${PACKAGE_NAME}' | Remove-AppxPackage`)
75
+ return installedVersion() === ''
76
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Open with dsh",
4
+ "description": "Adds an \"Open with dsh\" entry to the Windows Explorer context menu that opens the folder in dsh and starts a new session."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "通过 dsh 打开",
4
+ "description": "在 Windows 资源管理器的右键菜单里加入“通过 dsh 打开”,一点就在 dsh 中打开对应文件夹并新建会话。"
5
+ }
6
+ }
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ @echo off
2
+ rem Ask UAC to run trust-cert.ps1 elevated.
3
+ powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList '-NoProfile -ExecutionPolicy Bypass -NoExit -File \"%~dp0trust-cert.ps1\"'"
@@ -0,0 +1,23 @@
1
+ # Trust the self-signed code-signing certificate that signed DshOpenWith.msix.
2
+ # Must run elevated. Keep this file pure ASCII: PowerShell 5.1 decodes a BOM-less
3
+ # .ps1 with the ANSI code page, so non-ASCII text would be mangled.
4
+ $ErrorActionPreference = 'Stop'
5
+
6
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
7
+ $principal = New-Object Security.Principal.WindowsPrincipal($identity)
8
+ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
9
+ Write-Host 'ERROR: this script must run as Administrator.'
10
+ exit 1
11
+ }
12
+
13
+ $cer = Join-Path $PSScriptRoot 'dsh-open-with.cer'
14
+ if (-not (Test-Path -LiteralPath $cer)) {
15
+ Write-Host ('ERROR: certificate not found: ' + $cer)
16
+ exit 1
17
+ }
18
+
19
+ Import-Certificate -FilePath $cer -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' | Out-Null
20
+
21
+ $thumb = (Get-PfxCertificate -FilePath $cer).Thumbprint
22
+ Write-Host ('OK: certificate trusted (LocalMachine\TrustedPeople): ' + $thumb)
23
+ Write-Host 'Windows 11 modern context menu items can now load this package.'
@@ -0,0 +1 @@
1
+ 1.0.0.7
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "dsh-explorer-open",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Windows 资源管理器右键菜单里的「通过 dsh 打开」:在文件夹空白处、文件夹或文件上右键,直接在 dsh 中打开该文件夹并新建会话。",
5
+ "author": "ame-code",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ame-code/dsh-explorer-open.git"
9
+ },
10
+ "homepage": "https://github.com/ame-code/dsh-explorer-open#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/ame-code/dsh-explorer-open/issues"
13
+ },
14
+ "keywords": [
15
+ "dsh",
16
+ "deepseek-harness",
17
+ "windows",
18
+ "explorer",
19
+ "context-menu",
20
+ "iexplorercommand",
21
+ "msix"
22
+ ],
23
+ "license": "MIT",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./index.js",
27
+ "./client": "./client.js",
28
+ "./locale/*.json": "./locale/*.json",
29
+ "./icon": "./icon.svg"
30
+ },
31
+ "scripts": {
32
+ "test": "node test/client-test.mjs && node test/host-test.mjs"
33
+ },
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "files": [
38
+ "index.js",
39
+ "client.js",
40
+ "lib",
41
+ "native",
42
+ "assets",
43
+ "cordis.patch.yml",
44
+ "locale/*.json",
45
+ "icon.svg"
46
+ ],
47
+ "dsh": {
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ },
51
+ "client": {
52
+ "platform": "web",
53
+ "immediately": true,
54
+ "inject": [
55
+ "@deepseek-ai/dsh-client-ui-conversation"
56
+ ]
57
+ }
58
+ }
59
+ }