@sanqianx/claude-ai-workbench 0.11.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 +201 -0
- package/README.md +129 -0
- package/apps/agent-terminal/app.css +567 -0
- package/apps/agent-terminal/app.js +2392 -0
- package/apps/agent-terminal/index.html +158 -0
- package/apps/ai-profile-settings-host/index.html +25 -0
- package/apps/ai-profile-settings-host/page.css +1 -0
- package/apps/ai-profile-settings-host/page.js +113 -0
- package/apps/minimal-test-host/README.md +15 -0
- package/apps/minimal-test-host/host.css +2 -0
- package/apps/minimal-test-host/host.js +35 -0
- package/apps/minimal-test-host/index.html +58 -0
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
- package/docs/sdk/FILE_LAYOUT.md +31 -0
- package/docs/sdk/INTEGRATION.md +126 -0
- package/docs/sdk/README.md +21 -0
- package/docs/sdk/openapi.yaml +760 -0
- package/package.json +51 -0
- package/packages/client/README.md +11 -0
- package/packages/client/index.d.ts +81 -0
- package/packages/client/index.js +198 -0
- package/packages/client/package.json +29 -0
- package/packages/contracts/README.md +9 -0
- package/packages/contracts/index.d.ts +252 -0
- package/packages/contracts/index.js +295 -0
- package/packages/contracts/package.json +24 -0
- package/packages/core/README.md +8 -0
- package/packages/core/index.d.ts +76 -0
- package/packages/core/index.js +214 -0
- package/packages/core/package.json +29 -0
- package/packages/server/README.md +17 -0
- package/packages/server/bin/agent-terminal-server.js +36 -0
- package/packages/server/bin/claude-workbench-server.js +46 -0
- package/packages/server/bin/claude-workbench.js +225 -0
- package/packages/server/index.d.ts +145 -0
- package/packages/server/index.js +183 -0
- package/packages/server/lib/agent-discovery.js +112 -0
- package/packages/server/lib/agent-registry.js +98 -0
- package/packages/server/lib/attachment-store.js +78 -0
- package/packages/server/lib/bridges/app-server-bridge.js +372 -0
- package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
- package/packages/server/lib/capture-settings-store.js +34 -0
- package/packages/server/lib/context-resolver.js +40 -0
- package/packages/server/lib/drivers/cli-json-runner.js +960 -0
- package/packages/server/lib/drivers/codex-driver.js +1413 -0
- package/packages/server/lib/drivers/opencode-driver.js +1527 -0
- package/packages/server/lib/drivers/zcode-driver.js +1167 -0
- package/packages/server/lib/event-stream.js +35 -0
- package/packages/server/lib/folder-picker.js +623 -0
- package/packages/server/lib/http-handler.js +254 -0
- package/packages/server/lib/json-file.js +19 -0
- package/packages/server/lib/model-detection.js +295 -0
- package/packages/server/lib/profile-store.js +149 -0
- package/packages/server/lib/project-store.js +102 -0
- package/packages/server/lib/session-archive-store.js +53 -0
- package/packages/server/lib/system-doctor.js +119 -0
- package/packages/server/lib/ui-preferences-store.js +48 -0
- package/packages/server/lib/workbench-service.js +620 -0
- package/packages/server/package.json +34 -0
- package/packages/ui/README.md +30 -0
- package/packages/ui/ai-profile-settings.d.ts +45 -0
- package/packages/ui/ai-profile-settings.js +283 -0
- package/packages/ui/claude-terminal.d.ts +31 -0
- package/packages/ui/claude-terminal.js +343 -0
- package/packages/ui/index.d.ts +18 -0
- package/packages/ui/index.js +51 -0
- package/packages/ui/package.json +42 -0
- package/packages/ui/theme.css +13 -0
- package/src/assets/claude-prompts.json +21 -0
- package/src/backend/http/ai-profile-routes.js +61 -0
- package/src/backend/http/claude-routes.js +170 -0
- package/src/backend/http/node-helpers.js +45 -0
- package/src/backend/index.js +21 -0
- package/src/backend/lib/ai-adapter.js +144 -0
- package/src/backend/lib/ai-profile-store.js +259 -0
- package/src/backend/lib/ai-vendor-presets.js +83 -0
- package/src/backend/lib/ai-workspace.js +140 -0
- package/src/backend/lib/automation-config.js +253 -0
- package/src/backend/lib/claude-cli-runner.js +1749 -0
- package/src/backend/lib/data-dir.js +169 -0
- package/src/backend/lib/llm-client.js +176 -0
- package/src/backend/lib/model-context-windows.js +82 -0
- package/src/backend/lib/prompt-registry.js +166 -0
- package/src/backend/lib/runtime-config.js +121 -0
- package/src/index.js +7 -0
- package/src/sdk-wave-a.ts +1 -0
- package/src/sdk-wave-b.ts +1 -0
- package/src/sdk-wave-c.ts +1 -0
- package/src/sdk-wave-d.ts +1 -0
- package/src/sdk-wave-e.ts +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function normalizeEvent(event, sessionId, contextId, sequence, startedAt) {
|
|
4
|
+
const input = event && typeof event === 'object' ? event : { type: 'claude/error', message: String(event) };
|
|
5
|
+
const baseTime = Date.parse(startedAt || '') || Date.now();
|
|
6
|
+
return {
|
|
7
|
+
...input,
|
|
8
|
+
type: String(input.type || 'claude/state'),
|
|
9
|
+
sessionId,
|
|
10
|
+
contextId,
|
|
11
|
+
sequence,
|
|
12
|
+
at: input.at || new Date(baseTime + sequence).toISOString(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function subscribeNormalized(runner, sessionId, afterSequence, onEvent) {
|
|
17
|
+
const state = runner.getState(sessionId);
|
|
18
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
19
|
+
let sequence = 0;
|
|
20
|
+
return runner.subscribe(sessionId, raw => {
|
|
21
|
+
sequence += 1;
|
|
22
|
+
if (sequence <= afterSequence) return;
|
|
23
|
+
onEvent(normalizeEvent(raw, sessionId, state.projectSlug, sequence, state.startedAt));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function snapshotEvents(runner, sessionId) {
|
|
28
|
+
const session = runner.getSession(sessionId);
|
|
29
|
+
const state = runner.getState(sessionId);
|
|
30
|
+
if (!session || !state) return [];
|
|
31
|
+
return (session.outputBuffer || []).map((event, index) =>
|
|
32
|
+
normalizeEvent(event, sessionId, state.projectSlug, index + 1, state.startedAt));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { normalizeEvent, subscribeNormalized, snapshotEvents };
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
// UNIFIED native folder picker — three copies of this file are kept in
|
|
6
|
+
// lockstep (identical logic; only the module syntax differs):
|
|
7
|
+
// vector-hub src/server/folder-picker.ts (TypeScript twin)
|
|
8
|
+
// ai-coding-event-bridge packages/console/src/pick-folder.js
|
|
9
|
+
// claude-ai-workbench packages/server/lib/folder-picker.js
|
|
10
|
+
//
|
|
11
|
+
// Windows keeps the dialog the user already knows — the modern IFileDialog
|
|
12
|
+
// (the same folder picker Chrome/Edge surface: breadcrumb bar, Quick Access
|
|
13
|
+
// sidebar) driven by the battle-tested inline C# shim, with the
|
|
14
|
+
// AttachThreadInput foreground fix so the modal opens above the browser.
|
|
15
|
+
// What changed is the process model: spawning powershell.exe and running
|
|
16
|
+
// Add-Type (a full C# compilation) on every click cost 1-3s or more, so the
|
|
17
|
+
// helper is now started once in the background when the server boots
|
|
18
|
+
// (warmFolderPicker) and stays resident, serving every pick over a
|
|
19
|
+
// newline-framed stdin/stdout protocol. A click only writes one line and
|
|
20
|
+
// the dialog appears in milliseconds. The helper self-exits when its stdin
|
|
21
|
+
// pipe breaks, and the exit hook below kills it on shutdown.
|
|
22
|
+
//
|
|
23
|
+
// Other platforms keep one-shot helpers: zenity on Linux, osascript on macOS.
|
|
24
|
+
//
|
|
25
|
+
// Wire protocol (requests are pure ASCII; each reply is one line):
|
|
26
|
+
// -> ping <- !PONG
|
|
27
|
+
// -> pick <x> <y> <- !OK <selected path>
|
|
28
|
+
// -> pick - - <- !CANCEL (user dismissed the dialog)
|
|
29
|
+
// <- !ERR <message> (helper-side failure)
|
|
30
|
+
// and the first line after startup is !READY.
|
|
31
|
+
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // a user may browse for minutes
|
|
33
|
+
const STARTUP_TIMEOUT_MS = 60 * 1000; // powershell.exe start + Add-Type compile
|
|
34
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
35
|
+
const DEFAULT_PICKER_TITLE = '选择文件夹';
|
|
36
|
+
|
|
37
|
+
// One native dialog at a time: concurrent callers (double click, two tabs)
|
|
38
|
+
// share the in-flight pick instead of stacking modal dialogs.
|
|
39
|
+
let activePick = null;
|
|
40
|
+
|
|
41
|
+
const WINDOWS_DIALOG_ARGS = ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass'];
|
|
42
|
+
const WINDOWS_DIALOG_SCRIPT = `\
|
|
43
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
44
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
45
|
+
$cs = @"
|
|
46
|
+
using System;
|
|
47
|
+
using System.Runtime.InteropServices;
|
|
48
|
+
using System.Windows.Forms;
|
|
49
|
+
|
|
50
|
+
public class FolderPicker {
|
|
51
|
+
public string ResultPath { get; private set; }
|
|
52
|
+
|
|
53
|
+
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
|
54
|
+
private static extern int SHCreateItemFromParsingName(string path, IntPtr pbc, ref Guid riid, out IntPtr ppv);
|
|
55
|
+
[DllImport("user32.dll")]
|
|
56
|
+
private static extern IntPtr GetForegroundWindow();
|
|
57
|
+
[DllImport("user32.dll")]
|
|
58
|
+
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
59
|
+
[DllImport("user32.dll")]
|
|
60
|
+
private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
|
61
|
+
[DllImport("user32.dll")]
|
|
62
|
+
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
63
|
+
[DllImport("kernel32.dll")]
|
|
64
|
+
private static extern uint GetCurrentThreadId();
|
|
65
|
+
|
|
66
|
+
public bool ShowDialog(int cx, int cy) {
|
|
67
|
+
// The dialog centers on its owner, so the owner is a 1x1 transparent
|
|
68
|
+
// topmost window parked at the anchor point (browser window center,
|
|
69
|
+
// or the primary screen center when no anchor was supplied).
|
|
70
|
+
var owner = new Form();
|
|
71
|
+
owner.StartPosition = FormStartPosition.Manual;
|
|
72
|
+
owner.FormBorderStyle = FormBorderStyle.None;
|
|
73
|
+
owner.ShowInTaskbar = false;
|
|
74
|
+
owner.TopMost = true;
|
|
75
|
+
owner.Size = new System.Drawing.Size(1, 1);
|
|
76
|
+
try {
|
|
77
|
+
var area = Screen.FromPoint(new System.Drawing.Point(cx, cy)).WorkingArea;
|
|
78
|
+
if (cx < area.Left || cx > area.Right) cx = area.Left + area.Width / 2;
|
|
79
|
+
if (cy < area.Top || cy > area.Bottom) cy = area.Top + area.Height / 2;
|
|
80
|
+
} catch { }
|
|
81
|
+
owner.Location = new System.Drawing.Point(cx, cy);
|
|
82
|
+
owner.Opacity = 0;
|
|
83
|
+
owner.Show();
|
|
84
|
+
// A background process normally may not steal the foreground from the
|
|
85
|
+
// browser. Attaching our input thread to the foreground window's
|
|
86
|
+
// thread makes the activation legal (classic AttachThreadInput
|
|
87
|
+
// technique); the topmost owner then keeps the modal above the
|
|
88
|
+
// browser even when the OS still denies the focus change.
|
|
89
|
+
IntPtr fg = GetForegroundWindow();
|
|
90
|
+
uint fgPid;
|
|
91
|
+
uint fgThread = GetWindowThreadProcessId(fg, out fgPid);
|
|
92
|
+
uint thisThread = GetCurrentThreadId();
|
|
93
|
+
bool attached = fgThread != thisThread && fgThread != 0 && AttachThreadInput(thisThread, fgThread, true);
|
|
94
|
+
try {
|
|
95
|
+
SetForegroundWindow(owner.Handle);
|
|
96
|
+
owner.Activate();
|
|
97
|
+
} finally {
|
|
98
|
+
if (attached) AttachThreadInput(thisThread, fgThread, false);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
var dialog = (IFileDialog)(new FileOpenDialogRCW());
|
|
102
|
+
try {
|
|
103
|
+
dialog.SetOptions(FOS.PICKFOLDERS | FOS.FORCEFILESYSTEM | FOS.PATHMUSTEXIST);
|
|
104
|
+
dialog.SetTitle("__PICKER_TITLE__");
|
|
105
|
+
int hr = dialog.Show(owner.Handle);
|
|
106
|
+
if (hr != 0) return false;
|
|
107
|
+
IShellItem item;
|
|
108
|
+
dialog.GetResult(out item);
|
|
109
|
+
IntPtr pathPtr;
|
|
110
|
+
item.GetDisplayName(SIGDN.FILESYSPATH, out pathPtr);
|
|
111
|
+
ResultPath = Marshal.PtrToStringUni(pathPtr);
|
|
112
|
+
Marshal.FreeCoTaskMem(pathPtr);
|
|
113
|
+
Marshal.ReleaseComObject(item);
|
|
114
|
+
return true;
|
|
115
|
+
} finally { Marshal.ReleaseComObject(dialog); }
|
|
116
|
+
} finally { owner.Close(); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
[ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")]
|
|
120
|
+
private class FileOpenDialogRCW { }
|
|
121
|
+
|
|
122
|
+
[ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
123
|
+
private interface IShellItem {
|
|
124
|
+
// Full vtable order matters: COM slots before GetDisplayName must be
|
|
125
|
+
// declared even though this picker never calls them.
|
|
126
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
127
|
+
IShellItem GetParent();
|
|
128
|
+
[PreserveSig] int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName);
|
|
129
|
+
[PreserveSig] int GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
|
|
130
|
+
[PreserveSig] int Compare(IShellItem psi, uint hint);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
[ComImport, Guid("d57c7288-d4ad-4768-be02-9d969532d960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
134
|
+
private interface IFileDialog {
|
|
135
|
+
[PreserveSig] int Show(IntPtr hwndOwner);
|
|
136
|
+
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
|
|
137
|
+
void SetFileTypeIndex(uint iFileType);
|
|
138
|
+
void GetFileTypeIndex(out uint piFileType);
|
|
139
|
+
void Advise(IntPtr pfde, out uint pdwCookie);
|
|
140
|
+
void Unadvise(uint dwCookie);
|
|
141
|
+
void SetOptions(FOS fos);
|
|
142
|
+
void GetOptions(out FOS pfos);
|
|
143
|
+
void SetDefaultFolder(IShellItem psi);
|
|
144
|
+
void SetFolder(IShellItem psi);
|
|
145
|
+
void GetFolder(out IShellItem ppsi);
|
|
146
|
+
void GetCurrentSelection(out IShellItem ppsi);
|
|
147
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
|
148
|
+
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
|
|
149
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
|
|
150
|
+
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
|
|
151
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
|
|
152
|
+
void GetResult(out IShellItem ppsi);
|
|
153
|
+
void AddPlace(IShellItem psi, int fdap);
|
|
154
|
+
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
|
|
155
|
+
void Close(int hr);
|
|
156
|
+
void SetClientGuid(ref Guid guid);
|
|
157
|
+
void ClearClientData();
|
|
158
|
+
void SetFilter(IntPtr pFilter);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private enum SIGDN : uint { FILESYSPATH = 0x80058000 }
|
|
162
|
+
|
|
163
|
+
[Flags]
|
|
164
|
+
private enum FOS : uint {
|
|
165
|
+
PICKFOLDERS = 0x20,
|
|
166
|
+
FORCEFILESYSTEM = 0x40,
|
|
167
|
+
PATHMUSTEXIST = 0x800,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
"@
|
|
171
|
+
Add-Type -TypeDefinition $cs -ReferencedAssemblies System.Windows.Forms.dll,System.Drawing.dll
|
|
172
|
+
[Console]::Out.WriteLine('!READY')
|
|
173
|
+
while ($true) {
|
|
174
|
+
$line = [Console]::In.ReadLine()
|
|
175
|
+
if ($null -eq $line) { break }
|
|
176
|
+
$line = $line.Trim()
|
|
177
|
+
if ($line -eq 'ping') { [Console]::Out.WriteLine('!PONG'); continue }
|
|
178
|
+
if (-not $line.StartsWith('pick ')) { continue }
|
|
179
|
+
$bits = $line.Substring(5) -split ' '
|
|
180
|
+
$cx = $bits[0]
|
|
181
|
+
$cy = $bits[1]
|
|
182
|
+
if ($null -eq $cx -or $cx -eq '' -or $cx -eq '-' -or $null -eq $cy -or $cy -eq '' -or $cy -eq '-') {
|
|
183
|
+
$wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
|
|
184
|
+
$cx = $wa.X + [int]($wa.Width / 2)
|
|
185
|
+
$cy = $wa.Y + [int]($wa.Height / 2)
|
|
186
|
+
}
|
|
187
|
+
$picker = New-Object FolderPicker
|
|
188
|
+
try {
|
|
189
|
+
if ($picker.ShowDialog([int]$cx, [int]$cy)) {
|
|
190
|
+
[Console]::Out.WriteLine('!OK ' + $picker.ResultPath)
|
|
191
|
+
} else {
|
|
192
|
+
[Console]::Out.WriteLine('!CANCEL')
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
[Console]::Out.WriteLine('!ERR ' + $_.Exception.Message)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
`;
|
|
199
|
+
|
|
200
|
+
// The title lives inside the C# source that Add-Type compiles, so it is
|
|
201
|
+
// baked in when the helper starts; a different title restarts the helper.
|
|
202
|
+
// Quotes, backslashes, dollars and control characters would break either
|
|
203
|
+
// the C# literal or the PowerShell here-string and are stripped.
|
|
204
|
+
function sanitizeTitle(title) {
|
|
205
|
+
const value = String(title === undefined || title === null ? '' : title)
|
|
206
|
+
.replace(/["\\`$\r\n\t\u0000]/g, '')
|
|
207
|
+
.trim()
|
|
208
|
+
.slice(0, 128);
|
|
209
|
+
return value.length > 0 ? value : DEFAULT_PICKER_TITLE;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function windowsWorkerScript(title) {
|
|
213
|
+
return WINDOWS_DIALOG_SCRIPT.replace('__PICKER_TITLE__', sanitizeTitle(title));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The dialog centers on its owner, and the owner is parked at the caller's
|
|
217
|
+
// anchor point (the browser window center) — `windowRect` is the UI page's
|
|
218
|
+
// {x, y, width, height} in screen coordinates. Anything malformed falls
|
|
219
|
+
// back to the '-' markers, which make the helper center on the primary
|
|
220
|
+
// screen instead.
|
|
221
|
+
function anchorPoint(windowRect) {
|
|
222
|
+
if (windowRect && [windowRect.x, windowRect.y, windowRect.width, windowRect.height].every(Number.isFinite)) {
|
|
223
|
+
const x = Math.round(windowRect.x + Math.min(Math.max(windowRect.width, 0), 32768) / 2);
|
|
224
|
+
const y = Math.round(windowRect.y + Math.min(Math.max(windowRect.height, 0), 32768) / 2);
|
|
225
|
+
// Guard against absurd coordinates; Screen.FromPoint clamps the rest.
|
|
226
|
+
if (Math.abs(x) <= 100000 && Math.abs(y) <= 100000) return [x, y];
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ---- resident Windows helper (warm worker) ---- */
|
|
232
|
+
|
|
233
|
+
// One helper per spawn function: the real server shares node's spawn, so a
|
|
234
|
+
// single resident process serves every pick; injected test spawns each get
|
|
235
|
+
// an isolated worker. A strong set tracks live workers for the exit hook.
|
|
236
|
+
const workers = new WeakMap();
|
|
237
|
+
const liveWorkers = new Set();
|
|
238
|
+
|
|
239
|
+
function workerFor(doSpawn) {
|
|
240
|
+
let worker = workers.get(doSpawn);
|
|
241
|
+
if (!worker) {
|
|
242
|
+
worker = { child: null, ready: null, title: null, buffer: '', pending: null, ping: null, resolveReady: null, starting: false };
|
|
243
|
+
workers.set(doSpawn, worker);
|
|
244
|
+
}
|
|
245
|
+
return worker;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// While a request is in flight (helper startup, an open dialog, a ping) the
|
|
249
|
+
// worker's streams keep node alive so the reply is never lost; when idle
|
|
250
|
+
// they are unref'd again, letting a one-shot script exit once it is done.
|
|
251
|
+
// A listening server never notices either way.
|
|
252
|
+
function syncWorkerRefs(worker) {
|
|
253
|
+
const held = Boolean(worker.pending || worker.ping || worker.starting);
|
|
254
|
+
const child = worker.child;
|
|
255
|
+
if (!child) return;
|
|
256
|
+
[child, child.stdin, child.stdout, child.stderr].forEach((handle) => {
|
|
257
|
+
if (!handle) return;
|
|
258
|
+
if (held) {
|
|
259
|
+
if (handle.ref) handle.ref();
|
|
260
|
+
} else if (handle.unref) {
|
|
261
|
+
handle.unref();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function ensureWorker(worker, doSpawn, title, startupTimeoutMs) {
|
|
267
|
+
if (worker.title !== title) {
|
|
268
|
+
killWorker(worker);
|
|
269
|
+
worker.title = title;
|
|
270
|
+
}
|
|
271
|
+
if (!worker.ready) {
|
|
272
|
+
worker.ready = startWorker(worker, doSpawn, startupTimeoutMs).catch((error) => {
|
|
273
|
+
worker.ready = null;
|
|
274
|
+
throw error;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return worker.ready;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function startWorker(worker, doSpawn, startupTimeoutMs) {
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
let child;
|
|
283
|
+
try {
|
|
284
|
+
child = doSpawn('powershell.exe', [...WINDOWS_DIALOG_ARGS, '-Command', windowsWorkerScript(worker.title)], {
|
|
285
|
+
windowsHide: true,
|
|
286
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
reject(pickerError('powershell.exe', error));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
worker.child = child;
|
|
293
|
+
worker.starting = true;
|
|
294
|
+
worker.buffer = '';
|
|
295
|
+
liveWorkers.add(worker);
|
|
296
|
+
[child.stdin, child.stdout, child.stderr].forEach((stream) => {
|
|
297
|
+
if (stream && stream.on) stream.on('error', () => { /* EPIPE when the helper died */ });
|
|
298
|
+
});
|
|
299
|
+
syncWorkerRefs(worker);
|
|
300
|
+
let settled = false;
|
|
301
|
+
const timer = setTimeout(() => {
|
|
302
|
+
if (settled) return;
|
|
303
|
+
settled = true;
|
|
304
|
+
killWorker(worker);
|
|
305
|
+
reject(Object.assign(
|
|
306
|
+
new Error(`folder picker helper failed to start within ${Math.round(startupTimeoutMs / 1000)}s`),
|
|
307
|
+
{ status: 500 },
|
|
308
|
+
));
|
|
309
|
+
}, startupTimeoutMs);
|
|
310
|
+
if (timer.unref) timer.unref();
|
|
311
|
+
worker.resolveReady = () => {
|
|
312
|
+
if (settled) return;
|
|
313
|
+
settled = true;
|
|
314
|
+
worker.starting = false;
|
|
315
|
+
clearTimeout(timer);
|
|
316
|
+
syncWorkerRefs(worker);
|
|
317
|
+
resolve();
|
|
318
|
+
};
|
|
319
|
+
if (child.stdout && child.stdout.on) {
|
|
320
|
+
child.stdout.on('data', (chunk) => {
|
|
321
|
+
if (worker.buffer.length < MAX_OUTPUT) {
|
|
322
|
+
worker.buffer += String(chunk).slice(0, MAX_OUTPUT - worker.buffer.length);
|
|
323
|
+
}
|
|
324
|
+
drainWorkerLines(worker);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (child.stderr && child.stderr.on) child.stderr.on('data', () => { /* startup chatter */ });
|
|
328
|
+
child.on('error', (error) => {
|
|
329
|
+
if (!settled) {
|
|
330
|
+
settled = true;
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
reject(pickerError('powershell.exe', error));
|
|
333
|
+
}
|
|
334
|
+
worker.starting = false;
|
|
335
|
+
detachWorker(worker);
|
|
336
|
+
});
|
|
337
|
+
child.on('close', () => {
|
|
338
|
+
if (!settled) {
|
|
339
|
+
settled = true;
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
reject(Object.assign(new Error('folder picker helper exited before becoming ready'), { status: 500 }));
|
|
342
|
+
}
|
|
343
|
+
worker.starting = false;
|
|
344
|
+
detachWorker(worker);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function drainWorkerLines(worker) {
|
|
350
|
+
let index = worker.buffer.indexOf('\n');
|
|
351
|
+
while (index >= 0) {
|
|
352
|
+
const line = worker.buffer.slice(0, index).replace(/\r$/, '').replace(/^\uFEFF/, '').trim();
|
|
353
|
+
worker.buffer = worker.buffer.slice(index + 1);
|
|
354
|
+
handleWorkerLine(worker, line);
|
|
355
|
+
index = worker.buffer.indexOf('\n');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function handleWorkerLine(worker, line) {
|
|
360
|
+
if (line === '!READY') {
|
|
361
|
+
if (worker.resolveReady) worker.resolveReady();
|
|
362
|
+
} else if (line === '!PONG') {
|
|
363
|
+
const ping = worker.ping;
|
|
364
|
+
if (ping) {
|
|
365
|
+
worker.ping = null;
|
|
366
|
+
clearTimeout(ping.timer);
|
|
367
|
+
syncWorkerRefs(worker);
|
|
368
|
+
ping.resolve();
|
|
369
|
+
}
|
|
370
|
+
} else if (line === '!CANCEL') {
|
|
371
|
+
settleWorkerPick(worker, (pending) => pending.resolve({ path: null }));
|
|
372
|
+
} else if (line.startsWith('!OK ')) {
|
|
373
|
+
const path = parseSelectedPath(line.slice(4), false);
|
|
374
|
+
settleWorkerPick(worker, (pending) => pending.resolve({ path }));
|
|
375
|
+
} else if (line.startsWith('!ERR ')) {
|
|
376
|
+
settleWorkerPick(worker, (pending) => pending.reject(pickerError(
|
|
377
|
+
'folder picker helper',
|
|
378
|
+
Object.assign(new Error(line.slice(5)), { status: 500 }),
|
|
379
|
+
)));
|
|
380
|
+
killWorker(worker);
|
|
381
|
+
}
|
|
382
|
+
/* anything else is noise and ignored */
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function settleWorkerPick(worker, finish) {
|
|
386
|
+
const pending = worker.pending;
|
|
387
|
+
if (!pending) return;
|
|
388
|
+
worker.pending = null;
|
|
389
|
+
clearTimeout(pending.timer);
|
|
390
|
+
finish(pending);
|
|
391
|
+
syncWorkerRefs(worker);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function pickViaWindowsWorker(options) {
|
|
395
|
+
const doSpawn = options.spawn || spawn;
|
|
396
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
397
|
+
? options.timeoutMs
|
|
398
|
+
: DEFAULT_TIMEOUT_MS;
|
|
399
|
+
const startupTimeoutMs = Number.isFinite(options.startupTimeoutMs) && options.startupTimeoutMs > 0
|
|
400
|
+
? options.startupTimeoutMs
|
|
401
|
+
: STARTUP_TIMEOUT_MS;
|
|
402
|
+
const worker = workerFor(doSpawn);
|
|
403
|
+
await ensureWorker(worker, doSpawn, sanitizeTitle(options.title), startupTimeoutMs);
|
|
404
|
+
const anchor = anchorPoint(options.windowRect);
|
|
405
|
+
return await new Promise((resolve, reject) => {
|
|
406
|
+
const pending = { resolve, reject, timer: null };
|
|
407
|
+
pending.timer = setTimeout(() => {
|
|
408
|
+
// The helper is stuck mid-dialog and its state is unknown, so settle
|
|
409
|
+
// first (with the timeout message), then kill it — the next pick
|
|
410
|
+
// respawns a fresh helper.
|
|
411
|
+
settleWorkerPick(worker, (p) => p.reject(Object.assign(
|
|
412
|
+
new Error(`folder picker timed out after ${Math.round(timeoutMs / 1000)}s`),
|
|
413
|
+
{ status: 500 },
|
|
414
|
+
)));
|
|
415
|
+
killWorker(worker);
|
|
416
|
+
}, timeoutMs);
|
|
417
|
+
if (pending.timer.unref) pending.timer.unref();
|
|
418
|
+
worker.pending = pending;
|
|
419
|
+
syncWorkerRefs(worker);
|
|
420
|
+
if (!worker.child || !worker.child.stdin) {
|
|
421
|
+
settleWorkerPick(worker, (p) => p.reject(Object.assign(
|
|
422
|
+
new Error('folder picker helper is not running'),
|
|
423
|
+
{ status: 500 },
|
|
424
|
+
)));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
worker.child.stdin.write(anchor ? `pick ${anchor[0]} ${anchor[1]}\n` : 'pick - -\n');
|
|
429
|
+
} catch (error) {
|
|
430
|
+
settleWorkerPick(worker, (p) => p.reject(pickerError('folder picker helper', error)));
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function detachWorker(worker) {
|
|
436
|
+
liveWorkers.delete(worker);
|
|
437
|
+
worker.child = null;
|
|
438
|
+
worker.ready = null; // the helper died: force the next pick to respawn
|
|
439
|
+
worker.starting = false;
|
|
440
|
+
worker.buffer = '';
|
|
441
|
+
if (worker.pending) {
|
|
442
|
+
settleWorkerPick(worker, (pending) => pending.reject(Object.assign(
|
|
443
|
+
new Error('folder picker helper exited unexpectedly'),
|
|
444
|
+
{ status: 500 },
|
|
445
|
+
)));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function killWorker(worker) {
|
|
450
|
+
const child = worker.child;
|
|
451
|
+
worker.child = null;
|
|
452
|
+
worker.ready = null;
|
|
453
|
+
worker.starting = false;
|
|
454
|
+
liveWorkers.delete(worker);
|
|
455
|
+
if (worker.pending) {
|
|
456
|
+
settleWorkerPick(worker, (pending) => pending.reject(Object.assign(
|
|
457
|
+
new Error('folder picker helper was stopped'),
|
|
458
|
+
{ status: 500 },
|
|
459
|
+
)));
|
|
460
|
+
}
|
|
461
|
+
try { child && child.kill && child.kill(); } catch { /* already gone */ }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
process.on('exit', () => {
|
|
465
|
+
for (const worker of [...liveWorkers]) killWorker(worker);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
/* ---- public API ---- */
|
|
469
|
+
|
|
470
|
+
/** Opens the native folder picker. Resolves with `{ path: null }` on cancel. */
|
|
471
|
+
function pickFolder(options = {}) {
|
|
472
|
+
if (activePick) return activePick;
|
|
473
|
+
const shared = pickFolderUnlocked(options);
|
|
474
|
+
activePick = shared;
|
|
475
|
+
// Release on settle; the attached handler also means a dropped shared
|
|
476
|
+
// rejection never surfaces as unhandled.
|
|
477
|
+
const release = () => { if (activePick === shared) activePick = null; };
|
|
478
|
+
shared.then(release, release);
|
|
479
|
+
return shared;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function pickFolderUnlocked(options = {}) {
|
|
483
|
+
const platform = options.platform || process.platform;
|
|
484
|
+
if (platform === 'win32') {
|
|
485
|
+
return pickViaWindowsWorker(options);
|
|
486
|
+
}
|
|
487
|
+
const doSpawn = options.spawn || spawn;
|
|
488
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
489
|
+
? options.timeoutMs
|
|
490
|
+
: DEFAULT_TIMEOUT_MS;
|
|
491
|
+
const run = (command, args) => runOneShot(command, args, doSpawn, timeoutMs);
|
|
492
|
+
try {
|
|
493
|
+
const { stdout } = await run('zenity', ['--file-selection', '--directory']);
|
|
494
|
+
return { path: parseSelectedPath(stdout, false) };
|
|
495
|
+
} catch (error) {
|
|
496
|
+
// Only "not installed" falls through to osascript; real failures (crash,
|
|
497
|
+
// timeout) are already terminal.
|
|
498
|
+
if (error.status !== 501) throw error;
|
|
499
|
+
}
|
|
500
|
+
const { stdout } = await run('osascript', ['-e', 'POSIX path of (choose folder)']);
|
|
501
|
+
return { path: parseSelectedPath(stdout, true) };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Pre-starts the resident Windows helper (powershell.exe + one Add-Type
|
|
506
|
+
* compile) so the first user click only writes a stdin line. Call once at
|
|
507
|
+
* server startup; resolves false instead of throwing so a broken helper
|
|
508
|
+
* never takes the server down — the next pick retries the spawn.
|
|
509
|
+
*/
|
|
510
|
+
async function warmFolderPicker(options = {}) {
|
|
511
|
+
if ((options.platform || process.platform) !== 'win32') return false;
|
|
512
|
+
try {
|
|
513
|
+
const doSpawn = options.spawn || spawn;
|
|
514
|
+
const startupTimeoutMs = Number.isFinite(options.startupTimeoutMs) && options.startupTimeoutMs > 0
|
|
515
|
+
? options.startupTimeoutMs
|
|
516
|
+
: STARTUP_TIMEOUT_MS;
|
|
517
|
+
await ensureWorker(workerFor(doSpawn), doSpawn, sanitizeTitle(options.title), startupTimeoutMs);
|
|
518
|
+
return true;
|
|
519
|
+
} catch {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Health probe for the resident helper: round-trips `ping`/`!PONG` and
|
|
526
|
+
* resolves with the elapsed milliseconds (null off Windows). Also handy for
|
|
527
|
+
* measuring the warm click latency.
|
|
528
|
+
*/
|
|
529
|
+
async function pingFolderPicker(options = {}) {
|
|
530
|
+
if ((options.platform || process.platform) !== 'win32') return null;
|
|
531
|
+
const doSpawn = options.spawn || spawn;
|
|
532
|
+
const worker = workerFor(doSpawn);
|
|
533
|
+
const startupTimeoutMs = Number.isFinite(options.startupTimeoutMs) && options.startupTimeoutMs > 0
|
|
534
|
+
? options.startupTimeoutMs
|
|
535
|
+
: STARTUP_TIMEOUT_MS;
|
|
536
|
+
await ensureWorker(worker, doSpawn, sanitizeTitle(options.title), startupTimeoutMs);
|
|
537
|
+
const started = Date.now();
|
|
538
|
+
await new Promise((resolve, reject) => {
|
|
539
|
+
const ping = {
|
|
540
|
+
resolve,
|
|
541
|
+
timer: setTimeout(() => {
|
|
542
|
+
worker.ping = null;
|
|
543
|
+
killWorker(worker);
|
|
544
|
+
reject(Object.assign(new Error('folder picker ping timed out'), { status: 500 }));
|
|
545
|
+
}, 5000),
|
|
546
|
+
};
|
|
547
|
+
if (ping.timer.unref) ping.timer.unref();
|
|
548
|
+
worker.ping = ping;
|
|
549
|
+
syncWorkerRefs(worker);
|
|
550
|
+
if (!worker.child || !worker.child.stdin) {
|
|
551
|
+
worker.ping = null;
|
|
552
|
+
clearTimeout(ping.timer);
|
|
553
|
+
reject(Object.assign(new Error('folder picker helper is not running'), { status: 500 }));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
worker.child.stdin.write('ping\n');
|
|
558
|
+
} catch (error) {
|
|
559
|
+
worker.ping = null;
|
|
560
|
+
clearTimeout(ping.timer);
|
|
561
|
+
reject(pickerError('folder picker helper', error));
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
return Date.now() - started;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Empty output means the user cancelled: every backend keeps that contract.
|
|
568
|
+
function parseSelectedPath(stdout, stripTrailingSlashes) {
|
|
569
|
+
let value = String(stdout || '').replace(/\u0000/g, '').replace(/^\uFEFF/, '').trim();
|
|
570
|
+
value = value.replace(/^"|"$/g, '');
|
|
571
|
+
if (stripTrailingSlashes && value) value = value.replace(/\/+$/, '') || '/';
|
|
572
|
+
return value || null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function runOneShot(command, args, doSpawn, timeoutMs) {
|
|
576
|
+
return new Promise((resolve, reject) => {
|
|
577
|
+
let child;
|
|
578
|
+
try {
|
|
579
|
+
child = doSpawn(command, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
580
|
+
} catch (error) {
|
|
581
|
+
reject(pickerError(command, error));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
let stdout = '';
|
|
585
|
+
let settled = false;
|
|
586
|
+
const settle = finish => {
|
|
587
|
+
if (settled) return;
|
|
588
|
+
settled = true;
|
|
589
|
+
clearTimeout(timer);
|
|
590
|
+
finish();
|
|
591
|
+
};
|
|
592
|
+
const timer = setTimeout(() => settle(() => {
|
|
593
|
+
killChild(child);
|
|
594
|
+
reject(Object.assign(
|
|
595
|
+
new Error(`folder picker timed out after ${Math.round(timeoutMs / 1000)}s`),
|
|
596
|
+
{ status: 500 },
|
|
597
|
+
));
|
|
598
|
+
}), timeoutMs);
|
|
599
|
+
if (timer.unref) timer.unref();
|
|
600
|
+
if (child.stdout && child.stdout.on) {
|
|
601
|
+
child.stdout.on('data', chunk => {
|
|
602
|
+
if (stdout.length < MAX_OUTPUT) stdout += String(chunk).slice(0, MAX_OUTPUT - stdout.length);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (child.stderr && child.stderr.on) child.stderr.on('data', () => { /* cancellation chatter */ });
|
|
606
|
+
child.on('error', error => settle(() => reject(pickerError(command, error))));
|
|
607
|
+
child.on('close', () => settle(() => resolve({ stdout })));
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function pickerError(command, error) {
|
|
612
|
+
if (error && error.code === 'ENOENT') {
|
|
613
|
+
return Object.assign(new Error(`${command} is not available on this system`), { status: 501 });
|
|
614
|
+
}
|
|
615
|
+
const message = error && error.message ? error.message : 'unknown error';
|
|
616
|
+
return Object.assign(new Error(`failed to run ${command}: ${message}`), { status: 500 });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function killChild(child) {
|
|
620
|
+
try { child && child.kill && child.kill(); } catch { /* already gone */ }
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
module.exports = { pickFolder, warmFolderPicker, pingFolderPicker, parseSelectedPath };
|