@x1a0f3n9/dsh-host-directory-picker-native 0.1.5-rc.3
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 +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +113 -0
- package/README.zh.md +113 -0
- package/lib/index.js +303 -0
- package/lib/types/index.d.ts +26 -0
- package/lib/types/native-picker.d.ts +19 -0
- package/lib/types/win32-dialog-bindings.d.ts +27 -0
- package/lib/types/win32-dialog-host.d.ts +23 -0
- package/lib/types/win32-dialog-logic.d.ts +107 -0
- package/lib/types/win32-dialog-worker.d.ts +29 -0
- package/lib/types/win32-dialog.d.ts +49 -0
- package/lib/worker.cjs +253 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
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.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md
|
|
5
|
+
README.md: 3f980831848965b976e32b0e5ef25abb77fe4e6e
|
|
6
|
+
README.zh.md: 7a221cbbfebf3d79534e049800e26dab6ca39ad3
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Native-OS-chooser backend of the directory-picker seam: opens one platform chooser per pick for operators sitting at the web GUI host's display."
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @x1a0f3n9/dsh-host-directory-picker-native
|
|
7
|
+
|
|
8
|
+
English | [中文](README.zh.md)
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
An operator at the host's display picks a workspace directory through a native OS chooser: `dsh-host-directory-picker-native` opens one platform directory chooser per pick and resolves the chosen absolute path (`null` on cancel). macOS drives `osascript`, Linux uses Zenity with a KDialog fallback, and Windows opens the modern `IFileOpenDialog` in a spawned child process. Only viable when the operator sits at the host's display — remote deployments compose the [browse backend](../directory-picker-browse/README.md) instead. One composition row also registers the matching browser-side interaction in the workspace flow, so it selects both sides.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Further Exploration](#further-exploration)
|
|
19
|
+
- [Model Experience](#model-experience)
|
|
20
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
21
|
+
- [Dev Note](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## Use this package
|
|
27
|
+
|
|
28
|
+
Compose this backend when the operator works at the host's display and a native chooser is the right interaction. A workspace flow that opens a directory picker calls `pick(signal)` once per open request; the returned promise resolves with the chosen absolute path, or `null` when the operator cancels.
|
|
29
|
+
|
|
30
|
+
### When to choose it
|
|
31
|
+
|
|
32
|
+
Choose this backend for a workstation-local operator on macOS, Windows, or desktop Linux. Choose the [browse backend](../directory-picker-browse/README.md) when clients cannot reach an OS chooser — remote browsers, SSH-forwarded sessions, or unattended hosts. When the situation varies, the [adaptive chooser](../directory-picker-auto/README.md) resolves it at boot.
|
|
33
|
+
|
|
34
|
+
### What an operator experiences
|
|
35
|
+
|
|
36
|
+
Each call opens one native chooser on the host display and waits for the operator; aborting the caller's signal terminates the chooser process instead of leaving it open. On Linux the chooser needs either Zenity or KDialog installed; with neither present, `pick` rejects with an actionable error instead of falling back to a typed-path prompt. The browser half of this package registers a renderless flow occupant into the workspace flow — every `open` request drives `directoryPicker/pick` and reports the one outcome (picked path, cancel, or failure).
|
|
37
|
+
|
|
38
|
+
### Observable failures
|
|
39
|
+
|
|
40
|
+
A cancel returns `null`, not an error. Missing platform tooling, a failed chooser launch, or an aborted pick surfaces as a rejection the UI can present; the [browse backend](../directory-picker-browse/README.md) remains the composition-level fallback for deployments where native picking is unreliable.
|
|
41
|
+
|
|
42
|
+
-----
|
|
43
|
+
|
|
44
|
+
<a id="understand-the-implementation"></a>
|
|
45
|
+
## Understand the implementation
|
|
46
|
+
|
|
47
|
+
<details>
|
|
48
|
+
<summary>Implementation internals — click to expand</summary>
|
|
49
|
+
|
|
50
|
+
### Design concept
|
|
51
|
+
|
|
52
|
+
The backend is a thin service over a platform chooser: `NativeDirectoryPicker` registers the `native` capability whose `pick` forwards to `pickNativeDirectory`, and the chooser runs as a subprocess so the host process never blocks on the dialog. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable, and the shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
|
53
|
+
|
|
54
|
+
### Platform mechanics
|
|
55
|
+
|
|
56
|
+
Platform tools run without a shell: `osascript` on macOS, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Immediately before `Show`, the child synthesizes one Alt press through `keybd_event`, which lets the dialog activate as the foreground window even when a background host process spawned the child.
|
|
57
|
+
|
|
58
|
+
### Source map
|
|
59
|
+
|
|
60
|
+
| File | Role |
|
|
61
|
+
|---|---|
|
|
62
|
+
| [`src/index.ts`](src/index.ts) | Plugin entry: `NativeDirectoryPicker` service with the stable `native` capability |
|
|
63
|
+
| [`src/native-picker.ts`](src/native-picker.ts) | Chooser dispatch: platform selection, subprocess running, abort wiring |
|
|
64
|
+
| [`src/win32-dialog.ts`](src/win32-dialog.ts) + siblings | Windows child-process `IFileOpenDialog` via koffi, DPI handling, `WM_CLOSE` abort |
|
|
65
|
+
|
|
66
|
+
</details>
|
|
67
|
+
|
|
68
|
+
-----
|
|
69
|
+
|
|
70
|
+
<a id="further-exploration"></a>
|
|
71
|
+
## Further Exploration
|
|
72
|
+
|
|
73
|
+
Read these when the backend contract is not enough: the seam definition first, then the alternative backend and the chooser that selects between them.
|
|
74
|
+
|
|
75
|
+
- [Directory-picker seam](../directory-picker/README.md) — the `native` capability contract and the typed error vocabulary.
|
|
76
|
+
- [Directory-picker capability seam decision](../../../.agents/notes/archived/architecture/2026-07-28-directory-picker-capability-seam.md) — why backends differ in interaction shape.
|
|
77
|
+
- [Browse backend](../directory-picker-browse/README.md) — the in-app alternative for remote clients.
|
|
78
|
+
- [Adaptive chooser](../directory-picker-auto/README.md) — boot-time resolution between native and browse.
|
|
79
|
+
- [No-shell subprocess runner](../../util/native-command/README.md) — the shared subprocess primitive the chooser runs on.
|
|
80
|
+
|
|
81
|
+
-----
|
|
82
|
+
|
|
83
|
+
<a id="model-experience"></a>
|
|
84
|
+
## Model Experience
|
|
85
|
+
|
|
86
|
+
None, as the GUI-host picking backend registers nothing model-facing.
|
|
87
|
+
|
|
88
|
+
#### KV Cache effect
|
|
89
|
+
|
|
90
|
+
None; this package neither assembles nor sends a provider request.
|
|
91
|
+
|
|
92
|
+
## Known Limitations and Deferred Work
|
|
93
|
+
|
|
94
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
These limits define when the native interaction is unavailable or fragile. They are current package constraints, not a task backlog.
|
|
98
|
+
|
|
99
|
+
- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).
|
|
100
|
+
- **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure; the browse backend remains the fallback at the composition level.
|
|
101
|
+
- **Windows foreground grant relies on injected input** — the child synthesizes an Alt press before `Show` so the dialog can take the foreground from a background host; where synthesized input is suppressed (secure desktops, restricted remote sessions, an elevated foreground window), the dialog may still open behind other windows. The technique is validated on Windows 11 only.
|
|
102
|
+
|
|
103
|
+
<a id="dev-note"></a>
|
|
104
|
+
### Dev Note
|
|
105
|
+
|
|
106
|
+
<details>
|
|
107
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
108
|
+
|
|
109
|
+
None.
|
|
110
|
+
|
|
111
|
+
</details>
|
|
112
|
+
|
|
113
|
+
**Runtime invariant:** No companion is published. Each pick is one stateless subprocess round trip; the chooser outcome is only the returned path.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "目录选择 seam 的原生 OS 选择器后端:为坐在 web GUI 宿主屏幕前的操作者每次打开一个平台选择器。"
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @x1a0f3n9/dsh-host-directory-picker-native
|
|
7
|
+
|
|
8
|
+
[English](README.md) | 中文
|
|
9
|
+
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
坐在宿主屏幕前的操作者通过原生 OS 选择器选择工作区目录:`dsh-host-directory-picker-native` 每次选择打开一个平台目录选择器,并解析出所选绝对路径(取消时为 `null`)。macOS 驱动 `osascript`,Linux 使用 Zenity 并以 KDialog 回退,Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合[浏览后端](../directory-picker-browse/README.zh.md)。一行组合配置还会在工作区流程中注册匹配的浏览器侧交互,因此同时选择两侧。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [理解实现](#understand-the-implementation)
|
|
18
|
+
- [进一步探索](#further-exploration)
|
|
19
|
+
- [模型体验](#model-experience)
|
|
20
|
+
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
|
21
|
+
- [开发备注](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## 使用本包
|
|
27
|
+
|
|
28
|
+
当操作者工作在宿主屏幕前,且原生选择器是合适的交互时,组合此后端。打开目录选择器的工作区流程每个打开请求调用一次 `pick(signal)`;返回的 Promise 解析为所选绝对路径,操作者取消时解析为 `null`。
|
|
29
|
+
|
|
30
|
+
### 何时选择
|
|
31
|
+
|
|
32
|
+
为 macOS、Windows 或桌面 Linux 上的工作站本地操作者选择此后端。当客户端无法触达 OS 选择器时——远程浏览器、SSH 转发会话或无人值守宿主——请选择[浏览后端](../directory-picker-browse/README.zh.md)。具体情况不固定时,[自适应选择器](../directory-picker-auto/README.zh.md)会在启动时判定。
|
|
33
|
+
|
|
34
|
+
### 操作者会看到什么
|
|
35
|
+
|
|
36
|
+
每次调用在宿主屏幕上打开一个原生选择器并等待操作者;中止调用方的信号会终止选择器进程,而不是让它留在屏幕上。Linux 上选择器需要安装 Zenity 或 KDialog 之一;两者都没有时,`pick` 以包含解决建议的错误拒绝,而不会回退为手输路径提示。本包的浏览器端向工作区流程注册一个无渲染的流程占用者——每次 `open` 请求驱动 `directoryPicker/pick`,并上报唯一结果(所选路径、取消或失败)。
|
|
37
|
+
|
|
38
|
+
### 可观察的失败
|
|
39
|
+
|
|
40
|
+
取消返回 `null`,不是错误。平台工具缺失、选择器启动失败或选择操作中止都会导致 Promise 拒绝,界面可呈现相应错误;[浏览后端](../directory-picker-browse/README.zh.md)仍是原生目录选择不可靠时的组合层回退方案。
|
|
41
|
+
|
|
42
|
+
-----
|
|
43
|
+
|
|
44
|
+
<a id="understand-the-implementation"></a>
|
|
45
|
+
## 理解实现
|
|
46
|
+
|
|
47
|
+
<details>
|
|
48
|
+
<summary>实现细节——点击展开</summary>
|
|
49
|
+
|
|
50
|
+
### 设计理念
|
|
51
|
+
|
|
52
|
+
后端是平台选择器之上的一层薄服务:`NativeDirectoryPicker` 注册 `native` 能力,其 `pick` 转发给 `pickNativeDirectory`,选择器以子进程运行,因此宿主进程不会因对话框而阻塞。命令边界(`DirectoryPickerRunner`)与平台事实可注入;共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.zh.md)。
|
|
53
|
+
|
|
54
|
+
### 平台机制
|
|
55
|
+
|
|
56
|
+
平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。在 `Show` 之前,子进程立即通过 `keybd_event` 合成一次 Alt 按键,让对话框即使由后台宿主进程 spawn 也能激活为前台窗口。
|
|
57
|
+
|
|
58
|
+
### 源码地图
|
|
59
|
+
|
|
60
|
+
| 文件 | 职责 |
|
|
61
|
+
|---|---|
|
|
62
|
+
| [`src/index.ts`](src/index.ts) | 插件入口:持有稳定 `native` 能力的 `NativeDirectoryPicker` 服务 |
|
|
63
|
+
| [`src/native-picker.ts`](src/native-picker.ts) | 选择器分发:平台选择、子进程运行、中止接线 |
|
|
64
|
+
| [`src/win32-dialog.ts`](src/win32-dialog.ts) 及同族文件 | Windows 经 koffi 的子进程 `IFileOpenDialog`、DPI 处理、`WM_CLOSE` 中止 |
|
|
65
|
+
|
|
66
|
+
</details>
|
|
67
|
+
|
|
68
|
+
-----
|
|
69
|
+
|
|
70
|
+
<a id="further-exploration"></a>
|
|
71
|
+
## 进一步探索
|
|
72
|
+
|
|
73
|
+
当后端约定不够用时阅读以下内容:先看 seam 定义,再看替代后端与在两者之间选择的那个选择器。
|
|
74
|
+
|
|
75
|
+
- [目录选择 seam](../directory-picker/README.zh.md)——`native` 能力约定与类型化错误词汇。
|
|
76
|
+
- [目录选择能力 seam 决策](../../../.agents/notes/archived/architecture/2026-07-28-directory-picker-capability-seam.md)——后端为何在交互形态上彼此不同。
|
|
77
|
+
- [浏览后端](../directory-picker-browse/README.zh.md)——面向远程客户端的应用内替代方案。
|
|
78
|
+
- [自适应选择器](../directory-picker-auto/README.zh.md)——native 与 browse 之间的启动时判定。
|
|
79
|
+
- [免 shell 子进程运行器](../../util/native-command/README.zh.md)——选择器运行所依赖的共享子进程原语。
|
|
80
|
+
|
|
81
|
+
-----
|
|
82
|
+
|
|
83
|
+
<a id="model-experience"></a>
|
|
84
|
+
## 模型体验
|
|
85
|
+
|
|
86
|
+
无。GUI 宿主的目录选择后端不注册任何面向模型的内容。
|
|
87
|
+
|
|
88
|
+
#### KV Cache 影响
|
|
89
|
+
|
|
90
|
+
无;该包既不组装也不发送提供方请求。
|
|
91
|
+
|
|
92
|
+
## 已知限制与延期工作
|
|
93
|
+
|
|
94
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
这些限制说明原生交互何时不可用或不稳定。它们是当前包约束,不是任务积压。
|
|
98
|
+
|
|
99
|
+
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是浏览后端)。
|
|
100
|
+
- **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败;组合层面的回退仍是浏览后端。
|
|
101
|
+
- **Windows 前台授权依赖注入的输入**——子进程在 `Show` 之前合成一次 Alt 按键,对话框才能从后台宿主取得前台;在合成输入被抑制的环境(安全桌面、受限远程会话、提权前台窗口)中,对话框仍可能在其他窗口后面打开。该技术仅在 Windows 11 上验证过。
|
|
102
|
+
|
|
103
|
+
<a id="dev-note"></a>
|
|
104
|
+
### 开发备注
|
|
105
|
+
|
|
106
|
+
<details>
|
|
107
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
108
|
+
|
|
109
|
+
无。
|
|
110
|
+
|
|
111
|
+
</details>
|
|
112
|
+
|
|
113
|
+
**运行时不变式:** 不发布伴生入口。每次选择都是一次无状态的子进程往返;选择器的结果仅为返回的路径。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { DirectoryPicker } from "@x1a0f3n9/dsh-host-directory-picker";
|
|
2
|
+
import { runNativeCommand } from "@x1a0f3n9/dsh-native-command";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
//#region lib/types/win32-dialog-bindings.js
|
|
6
|
+
const WM_CLOSE = 16;
|
|
7
|
+
/**
|
|
8
|
+
* Encode a canonical GUID string as its 16 little-endian bytes.
|
|
9
|
+
* @param text - the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` form.
|
|
10
|
+
* @returns the in-memory GUID bytes CoCreateInstance expects.
|
|
11
|
+
*/
|
|
12
|
+
function guidBytes(text) {
|
|
13
|
+
const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text);
|
|
14
|
+
const bytes = Buffer.alloc(16);
|
|
15
|
+
bytes.writeUInt32LE(parseInt(match[1], 16), 0);
|
|
16
|
+
bytes.writeUInt16LE(parseInt(match[2], 16), 4);
|
|
17
|
+
bytes.writeUInt16LE(parseInt(match[3], 16), 6);
|
|
18
|
+
Buffer.from(match[4] + match[5], "hex").copy(bytes, 8);
|
|
19
|
+
return bytes;
|
|
20
|
+
}
|
|
21
|
+
guidBytes("dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7");
|
|
22
|
+
guidBytes("d57c7288-d4ad-4768-be02-9d969532d960");
|
|
23
|
+
/**
|
|
24
|
+
* Post `WM_CLOSE` to every window of a native thread — the driver's abort
|
|
25
|
+
* lever against the worker blocked inside `Show`, after which `Show` returns
|
|
26
|
+
* `HRESULT_CANCELLED` and the worker unwinds normally.
|
|
27
|
+
* @param threadId - the dialog thread's native id (from the `showing` notice).
|
|
28
|
+
*/
|
|
29
|
+
async function closeThreadWindows(threadId) {
|
|
30
|
+
const koffi = (await import("koffi")).default;
|
|
31
|
+
const user32 = koffi.load("user32.dll");
|
|
32
|
+
const enumThreadWindows = user32.func("__stdcall", "EnumThreadWindows", "int", [
|
|
33
|
+
"uint32",
|
|
34
|
+
"void *",
|
|
35
|
+
"intptr"
|
|
36
|
+
]);
|
|
37
|
+
const postMessageW = user32.func("__stdcall", "PostMessageW", "int", [
|
|
38
|
+
"void *",
|
|
39
|
+
"uint32",
|
|
40
|
+
"uintptr",
|
|
41
|
+
"intptr"
|
|
42
|
+
]);
|
|
43
|
+
const protoEnumProc = koffi.proto("int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)");
|
|
44
|
+
const callback = koffi.register((hwnd) => {
|
|
45
|
+
postMessageW(hwnd, WM_CLOSE, 0, 0);
|
|
46
|
+
return 1;
|
|
47
|
+
}, koffi.pointer(protoEnumProc));
|
|
48
|
+
try {
|
|
49
|
+
enumThreadWindows(threadId, callback, 0);
|
|
50
|
+
} finally {
|
|
51
|
+
koffi.unregister(callback);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region lib/types/win32-dialog-host.js
|
|
56
|
+
/**
|
|
57
|
+
* Real-process half of the Win32 dialog driver: spawn the dialog child
|
|
58
|
+
* process (source or built plane) and close a dialog thread's windows. The
|
|
59
|
+
* module itself loads everywhere (the import chain from native-picker.ts is
|
|
60
|
+
* static); what stays win32-only is koffi, imported dynamically inside the
|
|
61
|
+
* bindings' functions. The driver's logic is tested against fakes of this
|
|
62
|
+
* surface instead.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* Spawn the dialog child process. Built consumers launch the bundled CJS
|
|
66
|
+
* entry next to this module under plain node; unbuilt (source) consumers
|
|
67
|
+
* bootstrap tsx first, mirroring the dsh CLI's source launch. The child
|
|
68
|
+
* opens its dialog as foreground on its own: `runFolderDialog` synthesizes
|
|
69
|
+
* an Alt press before `Show`, which matters when a background host spawned
|
|
70
|
+
* the child.
|
|
71
|
+
* @param data - the child payload (dialog title).
|
|
72
|
+
* @returns the spawned child process.
|
|
73
|
+
*/
|
|
74
|
+
function spawnDialogWorker(data) {
|
|
75
|
+
const env = {
|
|
76
|
+
...process.env,
|
|
77
|
+
DSH_DIALOG_TITLE: data.title
|
|
78
|
+
};
|
|
79
|
+
const stdio = [
|
|
80
|
+
"ignore",
|
|
81
|
+
"inherit",
|
|
82
|
+
"inherit",
|
|
83
|
+
"ipc"
|
|
84
|
+
];
|
|
85
|
+
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */
|
|
86
|
+
if (!import.meta.url.endsWith(".ts")) return spawn(process.execPath, [fileURLToPath(new URL("./worker.cjs", import.meta.url))], {
|
|
87
|
+
env,
|
|
88
|
+
stdio,
|
|
89
|
+
windowsHide: true
|
|
90
|
+
});
|
|
91
|
+
return spawn(process.execPath, [
|
|
92
|
+
"--import",
|
|
93
|
+
import.meta.resolve("tsx/esm"),
|
|
94
|
+
fileURLToPath(new URL("./win32-dialog-worker.ts", import.meta.url))
|
|
95
|
+
], {
|
|
96
|
+
env,
|
|
97
|
+
stdio,
|
|
98
|
+
windowsHide: true
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region lib/types/win32-dialog.js
|
|
103
|
+
/**
|
|
104
|
+
* Main-thread driver for the Win32 folder dialog: spawns the dialog child
|
|
105
|
+
* process (which blocks inside the modal `Show`), maps its message protocol
|
|
106
|
+
* onto a promise, and services aborts by posting `WM_CLOSE` to the dialog
|
|
107
|
+
* thread's windows until the child reports back. The real process/window
|
|
108
|
+
* surface is injectable so every driver path is testable on any platform.
|
|
109
|
+
*/
|
|
110
|
+
/** The dialog title every host shows. */
|
|
111
|
+
const DIALOG_TITLE = "Select Workspace Directory";
|
|
112
|
+
/** `WM_CLOSE` re-post cadence while an abort waits for the worker to unwind. */
|
|
113
|
+
const CLOSE_RETRY_MS = 150;
|
|
114
|
+
/** Abort-service attempts before force-terminating the worker. */
|
|
115
|
+
const CLOSE_MAX_ATTEMPTS = 20;
|
|
116
|
+
/** Fail loudly if the closed worker-to-driver union gains an unhandled member. */
|
|
117
|
+
/* v8 ignore start -- closed-union backstop; unreachable without a TypeScript contract violation */
|
|
118
|
+
function assertNever(value) {
|
|
119
|
+
throw new TypeError(`unknown win32 dialog worker message kind: ${String(value)}`);
|
|
120
|
+
}
|
|
121
|
+
/* v8 ignore stop */
|
|
122
|
+
/**
|
|
123
|
+
* Open the modern Win32 folder picker off the event loop.
|
|
124
|
+
* @param signal - caller lifetime; abort closes the dialog and rejects.
|
|
125
|
+
* @param internals - Worker/window hooks for deterministic tests.
|
|
126
|
+
* @returns the selected path, or null when the user cancels.
|
|
127
|
+
*/
|
|
128
|
+
async function pickWin32Directory(signal, internals = {}) {
|
|
129
|
+
if (signal.aborted) throw new Error("native directory picker aborted");
|
|
130
|
+
const spawnWorker = internals.spawnWorker ?? spawnDialogWorker;
|
|
131
|
+
const closeWindows = internals.closeThreadWindows ?? closeThreadWindows;
|
|
132
|
+
const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS;
|
|
133
|
+
const worker = spawnWorker({ title: DIALOG_TITLE });
|
|
134
|
+
let dialogThreadId;
|
|
135
|
+
let closeTimer;
|
|
136
|
+
let settled = false;
|
|
137
|
+
return await new Promise((resolve, reject) => {
|
|
138
|
+
const settle = (outcome) => {
|
|
139
|
+
if (settled) return;
|
|
140
|
+
settled = true;
|
|
141
|
+
if (closeTimer !== void 0) clearInterval(closeTimer);
|
|
142
|
+
signal.removeEventListener("abort", onAbort);
|
|
143
|
+
worker.unref?.();
|
|
144
|
+
outcome();
|
|
145
|
+
};
|
|
146
|
+
const postClose = () => {
|
|
147
|
+
if (dialogThreadId !== void 0) closeWindows(dialogThreadId).catch(() => void 0);
|
|
148
|
+
};
|
|
149
|
+
const serviceAbort = () => {
|
|
150
|
+
let attempts = 0;
|
|
151
|
+
closeTimer = setInterval(() => {
|
|
152
|
+
attempts += 1;
|
|
153
|
+
if (attempts > CLOSE_MAX_ATTEMPTS) {
|
|
154
|
+
settle(() => {
|
|
155
|
+
worker.kill();
|
|
156
|
+
reject(/* @__PURE__ */ new Error("native directory picker aborted (dialog unresponsive; worker killed)"));
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
postClose();
|
|
161
|
+
}, closeRetryMs);
|
|
162
|
+
postClose();
|
|
163
|
+
};
|
|
164
|
+
const onAbort = () => {
|
|
165
|
+
serviceAbort();
|
|
166
|
+
};
|
|
167
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
168
|
+
worker.on("message", (message) => {
|
|
169
|
+
switch (message.kind) {
|
|
170
|
+
case "showing":
|
|
171
|
+
dialogThreadId = message.threadId;
|
|
172
|
+
if (signal.aborted) postClose();
|
|
173
|
+
return;
|
|
174
|
+
case "done":
|
|
175
|
+
settle(() => {
|
|
176
|
+
if (signal.aborted) reject(/* @__PURE__ */ new Error("native directory picker aborted"));
|
|
177
|
+
else resolve(message.path);
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
case "error":
|
|
181
|
+
settle(() => {
|
|
182
|
+
reject(/* @__PURE__ */ new Error(`win32 folder dialog failed: ${message.message}`));
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
/* v8 ignore next 2 -- closed worker-owned union; a fourth kind becomes a compile error */
|
|
186
|
+
default: assertNever(message);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
worker.on("error", (error) => {
|
|
190
|
+
settle(() => {
|
|
191
|
+
reject(error);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
worker.on("exit", () => {
|
|
195
|
+
settle(() => {
|
|
196
|
+
reject(/* @__PURE__ */ new Error("win32 folder dialog worker exited before reporting a result"));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region lib/types/native-picker.js
|
|
203
|
+
/** Cross-platform native single-directory chooser behind the native backend's capability. */
|
|
204
|
+
function outputPath(stdout) {
|
|
205
|
+
const path = stdout.replace(/[\r\n]+$/, "");
|
|
206
|
+
return path === "" ? null : path;
|
|
207
|
+
}
|
|
208
|
+
function errorCode(error) {
|
|
209
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return void 0;
|
|
210
|
+
const code = error.code;
|
|
211
|
+
return typeof code === "string" || typeof code === "number" ? code : void 0;
|
|
212
|
+
}
|
|
213
|
+
function errorStderr(error) {
|
|
214
|
+
if (typeof error !== "object" || error === null || !("stderr" in error)) return "";
|
|
215
|
+
const stderr = error.stderr;
|
|
216
|
+
return typeof stderr === "string" ? stderr : "";
|
|
217
|
+
}
|
|
218
|
+
function isMissingCommand(error) {
|
|
219
|
+
return errorCode(error) === "ENOENT";
|
|
220
|
+
}
|
|
221
|
+
function rethrowIfAborted(signal, error) {
|
|
222
|
+
if (signal.aborted) throw error;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Open the platform directory picker.
|
|
226
|
+
* @param signal - caller/connection lifetime; abort terminates the native command.
|
|
227
|
+
* @param internals - Platform and runner hooks for deterministic tests.
|
|
228
|
+
* @returns the selected path, or null when the user cancels.
|
|
229
|
+
*/
|
|
230
|
+
async function pickNativeDirectory(signal, internals = {}) {
|
|
231
|
+
const platform = internals.platform ?? process.platform;
|
|
232
|
+
const run = internals.run ?? runNativeCommand;
|
|
233
|
+
if (platform === "darwin") try {
|
|
234
|
+
return outputPath((await run("osascript", [
|
|
235
|
+
"-e",
|
|
236
|
+
"set selectedFolder to choose folder with prompt \"Select Workspace Directory\"",
|
|
237
|
+
"-e",
|
|
238
|
+
"POSIX path of selectedFolder"
|
|
239
|
+
], signal)).stdout);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (!signal.aborted && errorCode(error) === 1 && /(?:User canceled|-128)/i.test(errorStderr(error))) return null;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
if (platform === "win32") return await (internals.pickWin32Dialog ?? pickWin32Directory)(signal);
|
|
245
|
+
if (platform === "linux") {
|
|
246
|
+
try {
|
|
247
|
+
return outputPath((await run("zenity", [
|
|
248
|
+
"--file-selection",
|
|
249
|
+
"--directory",
|
|
250
|
+
"--title=Select Workspace Directory"
|
|
251
|
+
], signal)).stdout);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
rethrowIfAborted(signal, error);
|
|
254
|
+
if (errorCode(error) === 1) return null;
|
|
255
|
+
if (!isMissingCommand(error)) throw error;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
return outputPath((await run("kdialog", [
|
|
259
|
+
"--getexistingdirectory",
|
|
260
|
+
".",
|
|
261
|
+
"--title",
|
|
262
|
+
"Select Workspace Directory"
|
|
263
|
+
], signal)).stdout);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
rethrowIfAborted(signal, error);
|
|
266
|
+
if (errorCode(error) === 1) return null;
|
|
267
|
+
if (isMissingCommand(error)) throw new Error("no supported native directory picker found (install zenity or kdialog)");
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
throw new Error(`native directory picker is unsupported on ${platform}`);
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region lib/types/index.js
|
|
275
|
+
/**
|
|
276
|
+
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
|
|
277
|
+
* with the `native` capability, opening one native OS chooser on the host
|
|
278
|
+
* display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback;
|
|
279
|
+
* Windows opens the modern `IFileOpenDialog` in a spawned child process — a
|
|
280
|
+
* koffi-driven COM conversation on the child's main thread, preceded by a
|
|
281
|
+
* synthesized Alt press so the dialog activates as foreground even when a
|
|
282
|
+
* background host spawned the child). Only viable when
|
|
283
|
+
* the operator sits at the host's screen; remote deployments compose the
|
|
284
|
+
* browse backend instead.
|
|
285
|
+
* @module @x1a0f3n9/dsh-host-directory-picker-native
|
|
286
|
+
*/
|
|
287
|
+
/** The `ctx.directoryPicker` native implementation (stable capability object per service life). */
|
|
288
|
+
var NativeDirectoryPicker = class extends DirectoryPicker {
|
|
289
|
+
nativeCapability = {
|
|
290
|
+
kind: "native",
|
|
291
|
+
/* v8 ignore next -- pure forward to pickNativeDirectory (its spec owns behavior); invoking here opens a real chooser. */
|
|
292
|
+
pick: (signal) => pickNativeDirectory(signal)
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* The native interaction capability.
|
|
296
|
+
* @returns the stable `native` capability object.
|
|
297
|
+
*/
|
|
298
|
+
capability() {
|
|
299
|
+
return this.nativeCapability;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
//#endregion
|
|
303
|
+
export { NativeDirectoryPicker as default, pickNativeDirectory };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
|
|
3
|
+
* with the `native` capability, opening one native OS chooser on the host
|
|
4
|
+
* display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback;
|
|
5
|
+
* Windows opens the modern `IFileOpenDialog` in a spawned child process — a
|
|
6
|
+
* koffi-driven COM conversation on the child's main thread, preceded by a
|
|
7
|
+
* synthesized Alt press so the dialog activates as foreground even when a
|
|
8
|
+
* background host spawned the child). Only viable when
|
|
9
|
+
* the operator sits at the host's screen; remote deployments compose the
|
|
10
|
+
* browse backend instead.
|
|
11
|
+
* @module @x1a0f3n9/dsh-host-directory-picker-native
|
|
12
|
+
*/
|
|
13
|
+
import { DirectoryPicker } from '@x1a0f3n9/dsh-host-directory-picker';
|
|
14
|
+
import type { DirectoryPickerCapability } from '@x1a0f3n9/dsh-host-directory-picker';
|
|
15
|
+
export type { DirectoryPickerInternals, DirectoryPickerRunner } from './native-picker.ts';
|
|
16
|
+
export { pickNativeDirectory } from './native-picker.ts';
|
|
17
|
+
/** The `ctx.directoryPicker` native implementation (stable capability object per service life). */
|
|
18
|
+
export default class NativeDirectoryPicker extends DirectoryPicker {
|
|
19
|
+
private readonly nativeCapability;
|
|
20
|
+
/**
|
|
21
|
+
* The native interaction capability.
|
|
22
|
+
* @returns the stable `native` capability object.
|
|
23
|
+
*/
|
|
24
|
+
capability(): DirectoryPickerCapability;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Cross-platform native single-directory chooser behind the native backend's capability. */
|
|
2
|
+
import { type NativeCommandRunner } from '@x1a0f3n9/dsh-native-command';
|
|
3
|
+
/** Testable command boundary; native implementations never invoke a shell. */
|
|
4
|
+
export type DirectoryPickerRunner = NativeCommandRunner;
|
|
5
|
+
/** Injectable platform facts for deterministic adapter tests. */
|
|
6
|
+
export interface DirectoryPickerInternals {
|
|
7
|
+
platform?: NodeJS.Platform;
|
|
8
|
+
run?: DirectoryPickerRunner;
|
|
9
|
+
/** Replaces the in-process Win32 dialog (`pickWin32Directory`) for deterministic tests. */
|
|
10
|
+
pickWin32Dialog?: (signal: AbortSignal) => Promise<string | null>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Open the platform directory picker.
|
|
14
|
+
* @param signal - caller/connection lifetime; abort terminates the native command.
|
|
15
|
+
* @param internals - Platform and runner hooks for deterministic tests.
|
|
16
|
+
* @returns the selected path, or null when the user cancels.
|
|
17
|
+
*/
|
|
18
|
+
export declare function pickNativeDirectory(signal: AbortSignal, internals?: DirectoryPickerInternals): Promise<string | null>;
|
|
19
|
+
//# sourceMappingURL=native-picker.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* koffi-backed Win32 bindings for the folder dialog: the COM vtable calls
|
|
3
|
+
* behind {@link Win32DialogBindings} plus the cross-thread window closer the
|
|
4
|
+
* driver uses to service aborts. The module loads on every platform; koffi
|
|
5
|
+
* itself is imported lazily inside each function, so non-Windows processes
|
|
6
|
+
* never load it — the same containment as the repo's other `win32.ts`
|
|
7
|
+
* modules.
|
|
8
|
+
*
|
|
9
|
+
* The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and
|
|
10
|
+
* IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is
|
|
11
|
+
* frozen Windows ABI since Vista; slots are offsets into the vtable at the
|
|
12
|
+
* object's first pointer.
|
|
13
|
+
*/
|
|
14
|
+
import type { Win32DialogBindings } from './win32-dialog-logic.ts';
|
|
15
|
+
/**
|
|
16
|
+
* Load koffi and expose the dialog bindings for this thread.
|
|
17
|
+
* @returns the bindings {@link runFolderDialog} sequences against.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadWin32DialogBindings(): Promise<Win32DialogBindings>;
|
|
20
|
+
/**
|
|
21
|
+
* Post `WM_CLOSE` to every window of a native thread — the driver's abort
|
|
22
|
+
* lever against the worker blocked inside `Show`, after which `Show` returns
|
|
23
|
+
* `HRESULT_CANCELLED` and the worker unwinds normally.
|
|
24
|
+
* @param threadId - the dialog thread's native id (from the `showing` notice).
|
|
25
|
+
*/
|
|
26
|
+
export declare function closeThreadWindows(threadId: number): Promise<void>;
|
|
27
|
+
//# sourceMappingURL=win32-dialog-bindings.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-process half of the Win32 dialog driver: spawn the dialog child
|
|
3
|
+
* process (source or built plane) and close a dialog thread's windows. The
|
|
4
|
+
* module itself loads everywhere (the import chain from native-picker.ts is
|
|
5
|
+
* static); what stays win32-only is koffi, imported dynamically inside the
|
|
6
|
+
* bindings' functions. The driver's logic is tested against fakes of this
|
|
7
|
+
* surface instead.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import type { Win32DialogWorkerData } from './win32-dialog-worker.ts';
|
|
11
|
+
/**
|
|
12
|
+
* Spawn the dialog child process. Built consumers launch the bundled CJS
|
|
13
|
+
* entry next to this module under plain node; unbuilt (source) consumers
|
|
14
|
+
* bootstrap tsx first, mirroring the dsh CLI's source launch. The child
|
|
15
|
+
* opens its dialog as foreground on its own: `runFolderDialog` synthesizes
|
|
16
|
+
* an Alt press before `Show`, which matters when a background host spawned
|
|
17
|
+
* the child.
|
|
18
|
+
* @param data - the child payload (dialog title).
|
|
19
|
+
* @returns the spawned child process.
|
|
20
|
+
*/
|
|
21
|
+
export declare function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType<typeof spawn>;
|
|
22
|
+
export { closeThreadWindows } from './win32-dialog-bindings.ts';
|
|
23
|
+
//# sourceMappingURL=win32-dialog-host.d.ts.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure sequencing of the Win32 `IFileOpenDialog` folder-picker COM
|
|
3
|
+
* conversation over injectable platform bindings, so every outcome path
|
|
4
|
+
* (selection, cancellation, HRESULT failure, cleanup ordering) is testable on
|
|
5
|
+
* any platform. The koffi-backed bindings live in
|
|
6
|
+
* `win32-dialog-bindings.ts`, which only a real win32 process ever loads.
|
|
7
|
+
*/
|
|
8
|
+
/** `HRESULT_FROM_WIN32(ERROR_CANCELLED)`: the user dismissed the dialog. */
|
|
9
|
+
export declare const HRESULT_CANCELLED: number;
|
|
10
|
+
/** `FOS_PICKFOLDERS`: the dialog selects directories, not files. */
|
|
11
|
+
export declare const FOS_PICKFOLDERS = 32;
|
|
12
|
+
/** `FOS_FORCEFILESYSTEM`: only results with a filesystem path can be chosen. */
|
|
13
|
+
export declare const FOS_FORCEFILESYSTEM = 64;
|
|
14
|
+
/** `FOS_NOCHANGEDIR`: never mutate the process working directory. */
|
|
15
|
+
export declare const FOS_NOCHANGEDIR = 8;
|
|
16
|
+
/** One created folder dialog: the vtable calls the sequencing needs. */
|
|
17
|
+
export interface Win32FolderDialog {
|
|
18
|
+
/**
|
|
19
|
+
* `IFileDialog::SetOptions`.
|
|
20
|
+
* @param options - the `FOS_*` flag union to apply.
|
|
21
|
+
* @returns the call's HRESULT.
|
|
22
|
+
*/
|
|
23
|
+
setOptions(options: number): number;
|
|
24
|
+
/**
|
|
25
|
+
* `IFileDialog::SetTitle`.
|
|
26
|
+
* @param title - the dialog title text.
|
|
27
|
+
* @returns the call's HRESULT.
|
|
28
|
+
*/
|
|
29
|
+
setTitle(title: string): number;
|
|
30
|
+
/**
|
|
31
|
+
* `IModalWindow::Show` with no owner window; blocks the calling thread
|
|
32
|
+
* until the user selects or dismisses.
|
|
33
|
+
* @returns the call's HRESULT (`HRESULT_CANCELLED` on dismissal).
|
|
34
|
+
*/
|
|
35
|
+
show(): number;
|
|
36
|
+
/**
|
|
37
|
+
* `IFileDialog::GetResult` + `IShellItem::GetDisplayName(SIGDN_FILESYSPATH)`,
|
|
38
|
+
* releasing the shell item and freeing the COM string.
|
|
39
|
+
* @returns the call chain's HRESULT and, on success, the selected path.
|
|
40
|
+
*/
|
|
41
|
+
resultPath(): {
|
|
42
|
+
hr: number;
|
|
43
|
+
path?: string;
|
|
44
|
+
};
|
|
45
|
+
/** Release the dialog's COM reference. */
|
|
46
|
+
release(): void;
|
|
47
|
+
}
|
|
48
|
+
/** The thread-level native surface the dialog sequencing runs against. */
|
|
49
|
+
export interface Win32DialogBindings {
|
|
50
|
+
/**
|
|
51
|
+
* Opt the calling thread into the best supported DPI awareness
|
|
52
|
+
* (per-monitor-v2, then per-monitor, then system-aware), checking each
|
|
53
|
+
* call's result. Best-effort on purpose: a host accepting none of them
|
|
54
|
+
* (or lacking the API, pre-1607) still shows the modern dialog — possibly
|
|
55
|
+
* blurry above 100 % scaling — because a cosmetic degradation must not
|
|
56
|
+
* cost the tier.
|
|
57
|
+
*/
|
|
58
|
+
setThreadDpiAwareness(): void;
|
|
59
|
+
/**
|
|
60
|
+
* `CoInitializeEx(COINIT_APARTMENTTHREADED)` on the calling thread.
|
|
61
|
+
* @returns the call's HRESULT (`S_FALSE` re-entry is still a success).
|
|
62
|
+
*/
|
|
63
|
+
coInitializeSta(): number;
|
|
64
|
+
/**
|
|
65
|
+
* `CoUninitialize` on the calling thread — COM requires one pairing call
|
|
66
|
+
* for every successful (including `S_FALSE`) `CoInitializeEx`, even on a
|
|
67
|
+
* thread that exits right after the conversation.
|
|
68
|
+
*/
|
|
69
|
+
coUninitialize(): void;
|
|
70
|
+
/**
|
|
71
|
+
* `CoCreateInstance(CLSID_FileOpenDialog)`.
|
|
72
|
+
* @returns the created dialog surface; throws when creation fails.
|
|
73
|
+
*/
|
|
74
|
+
createFolderDialog(): Win32FolderDialog;
|
|
75
|
+
/**
|
|
76
|
+
* `GetCurrentThreadId` — the native id a driver needs to close this
|
|
77
|
+
* thread's windows from outside.
|
|
78
|
+
* @returns the calling thread's native id.
|
|
79
|
+
*/
|
|
80
|
+
currentThreadId(): number;
|
|
81
|
+
/**
|
|
82
|
+
* Make the dialog that `Show` is about to create able to take the
|
|
83
|
+
* foreground. Windows grants activation only to the foreground process,
|
|
84
|
+
* to a process it started, or to a process that received recent input; a
|
|
85
|
+
* worker spawned by a background host (the web GUI server) qualifies for
|
|
86
|
+
* none, so the dialog would open behind every other window. Synthesizing
|
|
87
|
+
* one Alt press (down, then up) through `keybd_event` counts this process
|
|
88
|
+
* as the most recent input owner — a community-documented technique with
|
|
89
|
+
* no documented contract. Call immediately before `Show`. When the
|
|
90
|
+
* process already holds foreground rights (a console-launched CLI), the
|
|
91
|
+
* press is inert, but the focused window still receives the lone Alt and
|
|
92
|
+
* may briefly highlight its menu bar before the dialog activates.
|
|
93
|
+
*/
|
|
94
|
+
pressAltForForeground(): void;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Run one modal folder-picker conversation on the calling thread: DPI opt-in,
|
|
98
|
+
* STA init, dialog creation, `Show`, and result extraction, releasing the
|
|
99
|
+
* dialog on every path.
|
|
100
|
+
* @param bindings - the native surface (koffi-backed in production, fakes in tests).
|
|
101
|
+
* @param title - the dialog title text.
|
|
102
|
+
* @param onShowing - called with the native thread id immediately before the
|
|
103
|
+
* blocking `Show`, so a driver on another thread can close the dialog.
|
|
104
|
+
* @returns the selected filesystem path, or null when the user cancels.
|
|
105
|
+
*/
|
|
106
|
+
export declare function runFolderDialog(bindings: Win32DialogBindings, title: string, onShowing: (threadId: number) => void): string | null;
|
|
107
|
+
//# sourceMappingURL=win32-dialog-logic.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-process entry for the Win32 folder dialog: blocks THIS process
|
|
3
|
+
* inside the modal `Show` so the host event loop stays live, reporting over
|
|
4
|
+
* the IPC channel. Spawned as a child process (not a worker thread) so a
|
|
5
|
+
* native fault stays contained and the modal call never wedges the host.
|
|
6
|
+
* A background host (the web GUI server) leaves this process without
|
|
7
|
+
* foreground rights, so `runFolderDialog` synthesizes an Alt press
|
|
8
|
+
* immediately before `Show` and the dialog then activates as foreground.
|
|
9
|
+
* Protocol: `{kind:'showing',threadId}` right
|
|
10
|
+
* before the blocking call (the driver's abort lever needs the native
|
|
11
|
+
* thread id), then exactly one of `{kind:'done',path}` or
|
|
12
|
+
* `{kind:'error',message}`.
|
|
13
|
+
*/
|
|
14
|
+
/** The driver-to-child payload: the dialog title (passed via env). */
|
|
15
|
+
export interface Win32DialogWorkerData {
|
|
16
|
+
title: string;
|
|
17
|
+
}
|
|
18
|
+
/** One notice or outcome posted back to the driver. */
|
|
19
|
+
export type Win32DialogWorkerMessage = {
|
|
20
|
+
kind: 'showing';
|
|
21
|
+
threadId: number;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'done';
|
|
24
|
+
path: string | null;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'error';
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
//# sourceMappingURL=win32-dialog-worker.d.ts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-thread driver for the Win32 folder dialog: spawns the dialog child
|
|
3
|
+
* process (which blocks inside the modal `Show`), maps its message protocol
|
|
4
|
+
* onto a promise, and services aborts by posting `WM_CLOSE` to the dialog
|
|
5
|
+
* thread's windows until the child reports back. The real process/window
|
|
6
|
+
* surface is injectable so every driver path is testable on any platform.
|
|
7
|
+
*/
|
|
8
|
+
import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts';
|
|
9
|
+
/** The child-process surface the driver drives (satisfied by `node:child_process`). */
|
|
10
|
+
export interface Win32DialogWorkerLike {
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to a child-process event.
|
|
13
|
+
* @param event - `message`, `error`, or `exit`.
|
|
14
|
+
* @param listener - the event consumer.
|
|
15
|
+
*/
|
|
16
|
+
on(event: 'message', listener: (message: Win32DialogWorkerMessage) => void): unknown;
|
|
17
|
+
on(event: 'error', listener: (error: Error) => void): unknown;
|
|
18
|
+
on(event: 'exit', listener: (code: number) => void): unknown;
|
|
19
|
+
/**
|
|
20
|
+
* Force-stop the child; the abort path's last resort when `WM_CLOSE`
|
|
21
|
+
* never lands (e.g. the dialog window was never created).
|
|
22
|
+
* @returns whether a kill signal was delivered.
|
|
23
|
+
*/
|
|
24
|
+
kill(): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Release the event-loop reference. Called once the pick settles so a
|
|
27
|
+
* child stuck in the native modal call never blocks process exit.
|
|
28
|
+
*/
|
|
29
|
+
unref?(): void;
|
|
30
|
+
}
|
|
31
|
+
/** Injectable process surface for deterministic driver tests. */
|
|
32
|
+
export interface Win32DialogInternals {
|
|
33
|
+
/** Replaces the real child spawn (`win32-dialog-host.ts`). */
|
|
34
|
+
spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike;
|
|
35
|
+
/** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */
|
|
36
|
+
closeThreadWindows?: (threadId: number) => Promise<void>;
|
|
37
|
+
/** Abort-service cadence override so tests never wait wall-clock time. */
|
|
38
|
+
closeRetryMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/** The dialog title every host shows. */
|
|
41
|
+
export declare const DIALOG_TITLE = "Select Workspace Directory";
|
|
42
|
+
/**
|
|
43
|
+
* Open the modern Win32 folder picker off the event loop.
|
|
44
|
+
* @param signal - caller lifetime; abort closes the dialog and rejects.
|
|
45
|
+
* @param internals - Worker/window hooks for deterministic tests.
|
|
46
|
+
* @returns the selected path, or null when the user cancels.
|
|
47
|
+
*/
|
|
48
|
+
export declare function pickWin32Directory(signal: AbortSignal, internals?: Win32DialogInternals): Promise<string | null>;
|
|
49
|
+
//# sourceMappingURL=win32-dialog.d.ts.map
|
package/lib/worker.cjs
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
//#region lib/types/win32-dialog-bindings.js
|
|
2
|
+
/**
|
|
3
|
+
* koffi-backed Win32 bindings for the folder dialog: the COM vtable calls
|
|
4
|
+
* behind {@link Win32DialogBindings} plus the cross-thread window closer the
|
|
5
|
+
* driver uses to service aborts. The module loads on every platform; koffi
|
|
6
|
+
* itself is imported lazily inside each function, so non-Windows processes
|
|
7
|
+
* never load it — the same containment as the repo's other `win32.ts`
|
|
8
|
+
* modules.
|
|
9
|
+
*
|
|
10
|
+
* The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and
|
|
11
|
+
* IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is
|
|
12
|
+
* frozen Windows ABI since Vista; slots are offsets into the vtable at the
|
|
13
|
+
* object's first pointer.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Read a valid NUL-terminated UTF-16 allocation without an external buffer.
|
|
17
|
+
* Generic `koffi.decode(..., 'str16')` expects a pointer variable, so the
|
|
18
|
+
* buffer holds the string address rather than the string bytes.
|
|
19
|
+
* @param koffi - the loaded koffi binding.
|
|
20
|
+
* @param address - the string address surfaced by the `_Out_ void **` param.
|
|
21
|
+
* @param pointerSize - the process's pointer width (`koffi.sizeof('void *')`).
|
|
22
|
+
* @returns the decoded UTF-16 path.
|
|
23
|
+
*/
|
|
24
|
+
function readUtf16(koffi, address, pointerSize) {
|
|
25
|
+
const pointer = Buffer.alloc(8);
|
|
26
|
+
pointer.writeBigUInt64LE(BigInt(address));
|
|
27
|
+
return koffi.decode(pointer.subarray(0, pointerSize), "str16");
|
|
28
|
+
}
|
|
29
|
+
const COINIT_APARTMENTTHREADED = 2;
|
|
30
|
+
const CLSCTX_INPROC_SERVER = 1;
|
|
31
|
+
const SIGDN_FILESYSPATH = -2147123200;
|
|
32
|
+
/**
|
|
33
|
+
* Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10
|
|
34
|
+
* 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext`
|
|
35
|
+
* returns NULL for an unsupported context instead of throwing, so the caller
|
|
36
|
+
* cascades to the best one the host accepts; DPI stays a cosmetic
|
|
37
|
+
* best-effort — an unsupported host still gets the modern dialog.
|
|
38
|
+
*/
|
|
39
|
+
const DPI_AWARENESS_CONTEXTS = [
|
|
40
|
+
-4,
|
|
41
|
+
-3,
|
|
42
|
+
-2
|
|
43
|
+
];
|
|
44
|
+
/** `VK_MENU`: the synthesized Alt press's virtual key. */
|
|
45
|
+
const VK_MENU = 18;
|
|
46
|
+
/** `KEYEVENTF_KEYUP`: the synthesized Alt press's release flag. */
|
|
47
|
+
const KEYEVENTF_KEYUP = 2;
|
|
48
|
+
/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */
|
|
49
|
+
const SLOT_RELEASE = 2;
|
|
50
|
+
const SLOT_SHOW = 3;
|
|
51
|
+
const SLOT_SET_OPTIONS = 9;
|
|
52
|
+
const SLOT_SET_TITLE = 17;
|
|
53
|
+
const SLOT_GET_RESULT = 20;
|
|
54
|
+
/** IShellItem vtable slot for `GetDisplayName`. */
|
|
55
|
+
const SLOT_GET_DISPLAY_NAME = 5;
|
|
56
|
+
/**
|
|
57
|
+
* Encode a canonical GUID string as its 16 little-endian bytes.
|
|
58
|
+
* @param text - the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` form.
|
|
59
|
+
* @returns the in-memory GUID bytes CoCreateInstance expects.
|
|
60
|
+
*/
|
|
61
|
+
function guidBytes(text) {
|
|
62
|
+
const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text);
|
|
63
|
+
const bytes = Buffer.alloc(16);
|
|
64
|
+
bytes.writeUInt32LE(parseInt(match[1], 16), 0);
|
|
65
|
+
bytes.writeUInt16LE(parseInt(match[2], 16), 4);
|
|
66
|
+
bytes.writeUInt16LE(parseInt(match[3], 16), 6);
|
|
67
|
+
Buffer.from(match[4] + match[5], "hex").copy(bytes, 8);
|
|
68
|
+
return bytes;
|
|
69
|
+
}
|
|
70
|
+
const CLSID_FILE_OPEN_DIALOG = guidBytes("dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7");
|
|
71
|
+
const IID_IFILE_OPEN_DIALOG = guidBytes("d57c7288-d4ad-4768-be02-9d969532d960");
|
|
72
|
+
/**
|
|
73
|
+
* Load koffi and expose the dialog bindings for this thread.
|
|
74
|
+
* @returns the bindings {@link runFolderDialog} sequences against.
|
|
75
|
+
*/
|
|
76
|
+
async function loadWin32DialogBindings() {
|
|
77
|
+
const koffi = (await import("koffi")).default;
|
|
78
|
+
const ole32 = koffi.load("ole32.dll");
|
|
79
|
+
const user32 = koffi.load("user32.dll");
|
|
80
|
+
const kernel32 = koffi.load("kernel32.dll");
|
|
81
|
+
const pointerSize = koffi.sizeof("void *");
|
|
82
|
+
const coInitializeEx = ole32.func("__stdcall", "CoInitializeEx", "int32", ["void *", "uint32"]);
|
|
83
|
+
const coUninitialize = ole32.func("__stdcall", "CoUninitialize", "void", []);
|
|
84
|
+
const coCreateInstance = ole32.func("__stdcall", "CoCreateInstance", "int32", [
|
|
85
|
+
"void *",
|
|
86
|
+
"void *",
|
|
87
|
+
"uint32",
|
|
88
|
+
"void *",
|
|
89
|
+
"void *"
|
|
90
|
+
]);
|
|
91
|
+
const coTaskMemFree = ole32.func("__stdcall", "CoTaskMemFree", "void", ["void *"]);
|
|
92
|
+
const getCurrentThreadId = kernel32.func("__stdcall", "GetCurrentThreadId", "uint32", []);
|
|
93
|
+
const keybdEvent = user32.func("__stdcall", "keybd_event", "void", [
|
|
94
|
+
"uint8",
|
|
95
|
+
"uint8",
|
|
96
|
+
"uint32",
|
|
97
|
+
"uintptr"
|
|
98
|
+
]);
|
|
99
|
+
const protoShow = koffi.proto("int32 __stdcall DshDialogShow(void *self, void *owner)");
|
|
100
|
+
const protoSetOptions = koffi.proto("int32 __stdcall DshDialogSetOptions(void *self, uint32 options)");
|
|
101
|
+
const protoSetTitle = koffi.proto("int32 __stdcall DshDialogSetTitle(void *self, str16 title)");
|
|
102
|
+
const protoGetResult = koffi.proto("int32 __stdcall DshDialogGetResult(void *self, _Out_ void **item)");
|
|
103
|
+
const protoGetDisplayName = koffi.proto("int32 __stdcall DshItemGetDisplayName(void *self, int32 form, _Out_ void **name)");
|
|
104
|
+
const protoRelease = koffi.proto("uint32 __stdcall DshComRelease(void *self)");
|
|
105
|
+
/** Bind vtable slot `slot` of COM object `self` to a caller through `proto`. */
|
|
106
|
+
const method = (self, slot, proto) => {
|
|
107
|
+
const vtable = koffi.decode(self, "void *");
|
|
108
|
+
const fn = koffi.decode(vtable, slot * pointerSize, "void *");
|
|
109
|
+
return (...args) => koffi.call(fn, proto, self, ...args);
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
setThreadDpiAwareness: () => {
|
|
113
|
+
let setContext;
|
|
114
|
+
try {
|
|
115
|
+
setContext = user32.func("__stdcall", "SetThreadDpiAwarenessContext", "void *", ["intptr"]);
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const context of DPI_AWARENESS_CONTEXTS) if (setContext(context) !== null) return;
|
|
120
|
+
},
|
|
121
|
+
coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED),
|
|
122
|
+
coUninitialize: () => {
|
|
123
|
+
coUninitialize();
|
|
124
|
+
},
|
|
125
|
+
currentThreadId: () => getCurrentThreadId(),
|
|
126
|
+
pressAltForForeground: () => {
|
|
127
|
+
keybdEvent(VK_MENU, 0, 0, 0);
|
|
128
|
+
keybdEvent(VK_MENU, 0, KEYEVENTF_KEYUP, 0);
|
|
129
|
+
},
|
|
130
|
+
createFolderDialog: () => {
|
|
131
|
+
const out = Buffer.alloc(pointerSize);
|
|
132
|
+
const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out);
|
|
133
|
+
if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`);
|
|
134
|
+
const dialog = koffi.decode(out, "void *");
|
|
135
|
+
return {
|
|
136
|
+
setOptions: (options) => method(dialog, SLOT_SET_OPTIONS, protoSetOptions)(options),
|
|
137
|
+
setTitle: (title) => method(dialog, SLOT_SET_TITLE, protoSetTitle)(title),
|
|
138
|
+
show: () => method(dialog, SLOT_SHOW, protoShow)(null),
|
|
139
|
+
resultPath: () => {
|
|
140
|
+
const itemOut = [null];
|
|
141
|
+
const gotItem = method(dialog, SLOT_GET_RESULT, protoGetResult)(itemOut);
|
|
142
|
+
if (gotItem < 0) return { hr: gotItem };
|
|
143
|
+
const item = itemOut[0];
|
|
144
|
+
try {
|
|
145
|
+
const nameOut = [null];
|
|
146
|
+
const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut);
|
|
147
|
+
if (gotName < 0) return { hr: gotName };
|
|
148
|
+
const path = readUtf16(koffi, nameOut[0], pointerSize);
|
|
149
|
+
coTaskMemFree(nameOut[0]);
|
|
150
|
+
return {
|
|
151
|
+
hr: gotName,
|
|
152
|
+
path
|
|
153
|
+
};
|
|
154
|
+
} finally {
|
|
155
|
+
method(item, SLOT_RELEASE, protoRelease)();
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
release: () => {
|
|
159
|
+
method(dialog, SLOT_RELEASE, protoRelease)();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Throw when an HRESULT signals failure.
|
|
167
|
+
* @param hr - the HRESULT to check.
|
|
168
|
+
* @param what - the failing call's name for the error message.
|
|
169
|
+
* @returns the (successful) HRESULT unchanged.
|
|
170
|
+
*/
|
|
171
|
+
function check(hr, what) {
|
|
172
|
+
if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`);
|
|
173
|
+
return hr;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Run one modal folder-picker conversation on the calling thread: DPI opt-in,
|
|
177
|
+
* STA init, dialog creation, `Show`, and result extraction, releasing the
|
|
178
|
+
* dialog on every path.
|
|
179
|
+
* @param bindings - the native surface (koffi-backed in production, fakes in tests).
|
|
180
|
+
* @param title - the dialog title text.
|
|
181
|
+
* @param onShowing - called with the native thread id immediately before the
|
|
182
|
+
* blocking `Show`, so a driver on another thread can close the dialog.
|
|
183
|
+
* @returns the selected filesystem path, or null when the user cancels.
|
|
184
|
+
*/
|
|
185
|
+
function runFolderDialog(bindings, title, onShowing) {
|
|
186
|
+
bindings.setThreadDpiAwareness();
|
|
187
|
+
check(bindings.coInitializeSta(), "CoInitializeEx");
|
|
188
|
+
try {
|
|
189
|
+
const dialog = bindings.createFolderDialog();
|
|
190
|
+
try {
|
|
191
|
+
check(dialog.setOptions(104), "SetOptions");
|
|
192
|
+
check(dialog.setTitle(title), "SetTitle");
|
|
193
|
+
onShowing(bindings.currentThreadId());
|
|
194
|
+
bindings.pressAltForForeground();
|
|
195
|
+
const shown = dialog.show();
|
|
196
|
+
if (shown === -2147023673) return null;
|
|
197
|
+
check(shown, "Show");
|
|
198
|
+
const result = dialog.resultPath();
|
|
199
|
+
check(result.hr, "GetResult");
|
|
200
|
+
return result.path;
|
|
201
|
+
} finally {
|
|
202
|
+
dialog.release();
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
bindings.coUninitialize();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region lib/types/win32-dialog-worker.js
|
|
210
|
+
/**
|
|
211
|
+
* Child-process entry for the Win32 folder dialog: blocks THIS process
|
|
212
|
+
* inside the modal `Show` so the host event loop stays live, reporting over
|
|
213
|
+
* the IPC channel. Spawned as a child process (not a worker thread) so a
|
|
214
|
+
* native fault stays contained and the modal call never wedges the host.
|
|
215
|
+
* A background host (the web GUI server) leaves this process without
|
|
216
|
+
* foreground rights, so `runFolderDialog` synthesizes an Alt press
|
|
217
|
+
* immediately before `Show` and the dialog then activates as foreground.
|
|
218
|
+
* Protocol: `{kind:'showing',threadId}` right
|
|
219
|
+
* before the blocking call (the driver's abort lever needs the native
|
|
220
|
+
* thread id), then exactly one of `{kind:'done',path}` or
|
|
221
|
+
* `{kind:'error',message}`.
|
|
222
|
+
*/
|
|
223
|
+
const title = process.env.DSH_DIALOG_TITLE ?? "";
|
|
224
|
+
if (title === "") throw new Error("win32-dialog-worker: DSH_DIALOG_TITLE is required");
|
|
225
|
+
if (process.send === void 0) throw new Error("win32-dialog-worker must run as a child process with an IPC channel");
|
|
226
|
+
const send = process.send.bind(process);
|
|
227
|
+
const post = (message) => {
|
|
228
|
+
/* v8 ignore next 3 -- disconnect needs a live IPC channel the unit lane must not sever (built-worker.e2e.ts owns the real close path). */
|
|
229
|
+
send(message, () => {
|
|
230
|
+
if (process.connected) process.disconnect();
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
/* v8 ignore next 3 -- the handler exits(0), which would kill the unit lane; built-worker.e2e.ts owns the real disconnect lifecycle. */
|
|
234
|
+
process.on("disconnect", () => process.exit(0));
|
|
235
|
+
(async () => {
|
|
236
|
+
try {
|
|
237
|
+
post({
|
|
238
|
+
kind: "done",
|
|
239
|
+
path: runFolderDialog(await loadWin32DialogBindings(), title, (threadId) => {
|
|
240
|
+
post({
|
|
241
|
+
kind: "showing",
|
|
242
|
+
threadId
|
|
243
|
+
});
|
|
244
|
+
})
|
|
245
|
+
});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
post({
|
|
248
|
+
kind: "error",
|
|
249
|
+
message: error instanceof Error ? error.stack ?? error.message : String(error)
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
})();
|
|
253
|
+
//#endregion
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@x1a0f3n9/dsh-host-directory-picker-native",
|
|
3
|
+
"description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host",
|
|
4
|
+
"version": "0.1.5-rc.3",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/host/directory-picker-native"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./worker": {
|
|
22
|
+
"types": "./lib/types/win32-dialog-worker.d.ts",
|
|
23
|
+
"default": "./lib/worker.cjs"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/worker.cjs",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"koffi": "^3.1.0",
|
|
36
|
+
"@x1a0f3n9/dsh-host-directory-picker": "^0.1.5-rc.3",
|
|
37
|
+
"@x1a0f3n9/dsh-native-command": "^0.1.5-rc.3"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"tsx": "^4.19.2",
|
|
44
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
45
|
+
}
|
|
46
|
+
}
|