@xingjisoft/neutrino 0.6.2 → 0.7.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/README.md +6 -1
- package/assets/fast-planner.txt +57 -0
- package/assets/fast-replanner.txt +28 -0
- package/dist/agents.d.ts +25 -0
- package/dist/app.d.ts +206 -0
- package/dist/app.js +15 -0
- package/dist/background.d.ts +96 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +40 -3
- package/dist/browser.d.ts +18 -0
- package/dist/clipboard.d.ts +16 -0
- package/dist/colors.d.ts +29 -0
- package/dist/compact.d.ts +52 -0
- package/dist/config.d.ts +221 -0
- package/dist/config.js +19 -7
- package/dist/cwd.d.ts +19 -0
- package/dist/fast/artifacts.d.ts +26 -0
- package/dist/fast/artifacts.js +109 -0
- package/dist/fast/engine.d.ts +78 -0
- package/dist/fast/engine.js +657 -0
- package/dist/fast/evidence.d.ts +21 -0
- package/dist/fast/evidence.js +68 -0
- package/dist/fast/planner.d.ts +10 -0
- package/dist/fast/planner.js +51 -0
- package/dist/fast/pool.d.ts +17 -0
- package/dist/fast/pool.js +78 -0
- package/dist/fast/scheduler.d.ts +54 -0
- package/dist/fast/scheduler.js +383 -0
- package/dist/fast/types.d.ts +100 -0
- package/dist/fast/types.js +21 -0
- package/dist/fast/worker.d.ts +23 -0
- package/dist/fast/worker.js +185 -0
- package/dist/header.d.ts +3 -0
- package/dist/index.d.ts +5 -0
- package/dist/llm/approval.d.ts +204 -0
- package/dist/llm/approval.js +14 -3
- package/dist/llm/client.d.ts +118 -0
- package/dist/llm/client.js +57 -8
- package/dist/llm/codeExecutor.d.ts +14 -0
- package/dist/llm/codemode.d.ts +15 -0
- package/dist/llm/context.d.ts +29 -0
- package/dist/llm/diagnostics.d.ts +91 -0
- package/dist/llm/diagnostics.js +242 -0
- package/dist/llm/plan.d.ts +148 -0
- package/dist/llm/retry.d.ts +80 -0
- package/dist/llm/risk.d.ts +21 -0
- package/dist/llm/risk.js +7 -0
- package/dist/llm/runtime.d.ts +189 -0
- package/dist/llm/runtime.js +77 -4
- package/dist/llm/search.d.ts +2 -0
- package/dist/llm/searchNetwork.d.ts +8 -0
- package/dist/llm/subagent.d.ts +112 -0
- package/dist/llm/subagent.js +4 -2
- package/dist/llm/tools.d.ts +20 -0
- package/dist/llm/tools.js +4 -1
- package/dist/llm/workspace.d.ts +6 -0
- package/dist/llm/workspace.js +271 -0
- package/dist/markdown.d.ts +58 -0
- package/dist/migration.d.ts +8 -0
- package/dist/persistence.d.ts +14 -0
- package/dist/plugins/catalog.d.ts +20 -0
- package/dist/plugins/catalog.js +45 -0
- package/dist/plugins/cli.d.ts +1 -0
- package/dist/plugins/cli.js +106 -0
- package/dist/plugins/host.d.ts +121 -0
- package/dist/plugins/host.js +450 -0
- package/dist/plugins/manager.d.ts +24 -0
- package/dist/plugins/manager.js +288 -0
- package/dist/plugins/package.d.ts +5 -0
- package/dist/plugins/package.js +53 -0
- package/dist/plugins/runner.d.ts +1 -0
- package/dist/plugins/runner.js +227 -0
- package/dist/plugins/session.d.ts +12 -0
- package/dist/plugins/session.js +97 -0
- package/dist/plugins/terminal.d.ts +22 -0
- package/dist/plugins/terminal.js +33 -0
- package/dist/plugins/types.d.ts +178 -0
- package/dist/plugins/types.js +6 -0
- package/dist/plugins/validation.d.ts +14 -0
- package/dist/plugins/validation.js +174 -0
- package/dist/plugins/web-sdk.d.ts +58 -0
- package/dist/plugins/web-sdk.js +1 -0
- package/dist/pro/agents.d.ts +34 -0
- package/dist/pro/config.d.ts +75 -0
- package/dist/pro/config.js +3 -2
- package/dist/pro/preflight.d.ts +21 -0
- package/dist/sanitize.d.ts +34 -0
- package/dist/screens/agentConversation.d.ts +28 -0
- package/dist/screens/agentHistory.d.ts +25 -0
- package/dist/screens/agentSelect.d.ts +16 -0
- package/dist/screens/chat.d.ts +292 -0
- package/dist/screens/chat.js +139 -21
- package/dist/screens/history.d.ts +23 -0
- package/dist/screens/modelSelect.d.ts +29 -0
- package/dist/screens/plugins.d.ts +29 -0
- package/dist/screens/plugins.js +196 -0
- package/dist/screens/settings.d.ts +42 -0
- package/dist/screens/start.d.ts +43 -0
- package/dist/screens/start.js +25 -17
- package/dist/screens/updatePrompt.d.ts +41 -0
- package/dist/selection.d.ts +75 -0
- package/dist/state.d.ts +26 -0
- package/dist/terminal/buffer.d.ts +47 -0
- package/dist/terminal/input.d.ts +70 -0
- package/dist/terminal/renderer.d.ts +43 -0
- package/dist/terminal/restore.d.ts +41 -0
- package/dist/terminal/screen.d.ts +57 -0
- package/dist/tray.d.ts +14 -0
- package/dist/ui.d.ts +123 -0
- package/dist/update.d.ts +71 -0
- package/dist/updateWatch.d.ts +43 -0
- package/dist/web/approvalQueue.d.ts +8 -0
- package/dist/web/approvalQueue.js +59 -0
- package/dist/web/auth.d.ts +14 -0
- package/dist/web/clientState.d.ts +16 -0
- package/dist/web/folderPicker.d.ts +34 -0
- package/dist/web/folderPicker.js +544 -0
- package/dist/web/hub.d.ts +213 -0
- package/dist/web/hub.js +14 -2
- package/dist/web/localApi.d.ts +11 -0
- package/dist/web/localApi.js +260 -0
- package/dist/web/plugins.d.ts +8 -0
- package/dist/web/plugins.js +64 -0
- package/dist/web/server.d.ts +81 -0
- package/dist/web/server.js +127 -19
- package/dist/web/service.d.ts +7 -0
- package/dist/web/service.js +1 -0
- package/dist/web/session.d.ts +176 -0
- package/dist/web/session.js +153 -19
- package/dist/web/settings.d.ts +65 -0
- package/dist/web/terminalBridge.d.ts +15 -0
- package/dist/web/terminalBridge.js +8 -2
- package/dist/width.d.ts +6 -0
- package/docs/plugins/README.md +126 -0
- package/docs/plugins/agent-sdk.md +92 -0
- package/docs/plugins/architecture.md +64 -0
- package/docs/plugins/compatibility.md +34 -0
- package/docs/plugins/development.md +55 -0
- package/docs/plugins/manifest.md +59 -0
- package/docs/plugins/manifest.schema.json +286 -0
- package/docs/plugins/security.md +53 -0
- package/docs/plugins/skills-prompts.md +69 -0
- package/docs/plugins/ui-sdk.md +109 -0
- package/examples/local-api.py +105 -0
- package/examples/plugins/cards/card.html +6 -0
- package/examples/plugins/cards/neutrino.plugin.json +42 -0
- package/examples/plugins/composer/editor.html +10 -0
- package/examples/plugins/composer/neutrino.plugin.json +10 -0
- package/examples/plugins/function-tools/agent.js +32 -0
- package/examples/plugins/function-tools/neutrino.plugin.json +15 -0
- package/examples/plugins/menus-tabs/neutrino.plugin.json +15 -0
- package/examples/plugins/menus-tabs/panel.html +6 -0
- package/examples/plugins/skills-prompts/neutrino.plugin.json +13 -0
- package/examples/plugins/skills-prompts/prompts/evidence.md +1 -0
- package/examples/plugins/skills-prompts/prompts/reviewer.md +1 -0
- package/examples/plugins/skills-prompts/skills/review/SKILL.md +8 -0
- package/examples/plugins/theme/neutrino.plugin.json +12 -0
- package/package.json +66 -42
- package/web/LOCAL-API.md +65 -0
- package/web/README.md +27 -6
- package/web/app.js +201 -32
- package/web/i18n.js +15 -0
- package/web/index.html +3 -1
- package/web/plugins.css +10 -0
- package/web/plugins.js +230 -0
- package/web/style.css +11 -2
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operating system's own folder window, opened by the machine that runs the
|
|
3
|
+
* console.
|
|
4
|
+
*
|
|
5
|
+
* A browser's folder picker only ever hands back the *contents* it was allowed
|
|
6
|
+
* to read, never the absolute path, so a working directory cannot be chosen from
|
|
7
|
+
* the page itself: it has to be chosen where the agent runs, which is this
|
|
8
|
+
* process. Each platform draws its native window — the same dialog every other
|
|
9
|
+
* application shows — and when none can be drawn (a headless server, a minimal
|
|
10
|
+
* desktop without zenity or kdialog) the page keeps its own folder browser.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
const DEFAULT_TITLE = "Select a working directory";
|
|
17
|
+
/** A window nobody answers must never pin the HTTP request forever. */
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
19
|
+
const MAX_TITLE_LENGTH = 120;
|
|
20
|
+
/** Open the operating system's folder window and report what the user chose. */
|
|
21
|
+
export async function pickFolder(request = {}) {
|
|
22
|
+
const platform = request.platform ?? process.platform;
|
|
23
|
+
const env = { ...(request.env ?? process.env) };
|
|
24
|
+
const run = request.runner ?? runDialog;
|
|
25
|
+
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
26
|
+
const start = startFolder(request.start);
|
|
27
|
+
const title = dialogTitle(request.title);
|
|
28
|
+
// Both scripts read their inputs from the environment: a path is passed to the
|
|
29
|
+
// child without quoting rules, spaces and quotes included.
|
|
30
|
+
env.NEUTRINO_PICKER_START = start;
|
|
31
|
+
env.NEUTRINO_PICKER_TITLE = title;
|
|
32
|
+
if (platform === "win32")
|
|
33
|
+
return pickWindows(run, env, timeoutMs);
|
|
34
|
+
if (platform === "darwin")
|
|
35
|
+
return pickMac(run, env, timeoutMs, start, title);
|
|
36
|
+
if (platform === "linux")
|
|
37
|
+
return pickLinux(run, env, timeoutMs, start, title);
|
|
38
|
+
return { unavailable: `the system folder window is not available on ${platform}` };
|
|
39
|
+
}
|
|
40
|
+
// ------------------------------------------------------------------ windows
|
|
41
|
+
/**
|
|
42
|
+
* Windows PowerShell is present on every Windows Node supports; `pwsh` is the
|
|
43
|
+
* fallback for a machine where it was removed.
|
|
44
|
+
*/
|
|
45
|
+
async function pickWindows(run, env, timeoutMs) {
|
|
46
|
+
let dir;
|
|
47
|
+
try {
|
|
48
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "neutrino-folder-"));
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return { unavailable: reason(err) };
|
|
52
|
+
}
|
|
53
|
+
const script = path.join(dir, "folder-window.ps1");
|
|
54
|
+
try {
|
|
55
|
+
fs.writeFileSync(script, WINDOWS_DIALOG, "utf8");
|
|
56
|
+
let missing = "";
|
|
57
|
+
for (const shell of ["powershell.exe", "pwsh.exe"]) {
|
|
58
|
+
const result = await run(shell, ["-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-File", script], env, timeoutMs);
|
|
59
|
+
if (result.missing) {
|
|
60
|
+
missing = `${shell} is not installed`;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const picked = result.stdout.trim();
|
|
64
|
+
if (picked)
|
|
65
|
+
return { picked };
|
|
66
|
+
if (result.code === 0)
|
|
67
|
+
return { cancelled: true };
|
|
68
|
+
return { unavailable: lastLine(result.stderr) || `the system folder window failed (exit ${result.code})` };
|
|
69
|
+
}
|
|
70
|
+
return { unavailable: missing || "no PowerShell is installed" };
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
try {
|
|
74
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A leftover temp folder is harmless.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// -------------------------------------------------------------------- macOS
|
|
82
|
+
async function pickMac(run, env, timeoutMs, start, title) {
|
|
83
|
+
const result = await run("osascript", ["-e", MAC_DIALOG, title, start], env, timeoutMs);
|
|
84
|
+
if (result.missing)
|
|
85
|
+
return { unavailable: "osascript is not installed" };
|
|
86
|
+
if (result.code === 0) {
|
|
87
|
+
// `POSIX path of` ends a folder with a separator; the rest of the console
|
|
88
|
+
// stores working directories without one.
|
|
89
|
+
const picked = trimTrailingSeparator(result.stdout.trim());
|
|
90
|
+
return picked ? { picked } : { cancelled: true };
|
|
91
|
+
}
|
|
92
|
+
// AppleScript reports a closed window as "User canceled. (-128)".
|
|
93
|
+
if (/-128\b/.test(result.stderr) || /cancel/i.test(result.stderr))
|
|
94
|
+
return { cancelled: true };
|
|
95
|
+
return { unavailable: lastLine(result.stderr) || "the system folder window failed" };
|
|
96
|
+
}
|
|
97
|
+
// -------------------------------------------------------------------- linux
|
|
98
|
+
async function pickLinux(run, env, timeoutMs, start, title) {
|
|
99
|
+
// Both dialogs draw on a desktop session, and only a local user has one.
|
|
100
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
101
|
+
return { unavailable: "no desktop session is available" };
|
|
102
|
+
const attempts = [
|
|
103
|
+
{
|
|
104
|
+
command: "zenity",
|
|
105
|
+
args: [
|
|
106
|
+
"--file-selection",
|
|
107
|
+
"--directory",
|
|
108
|
+
`--title=${title}`,
|
|
109
|
+
...(start ? [`--filename=${trimTrailingSeparator(start)}${path.sep}`] : []),
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{ command: "kdialog", args: ["--title", title, "--getexistingdirectory", start] },
|
|
113
|
+
];
|
|
114
|
+
let missing = "";
|
|
115
|
+
for (const attempt of attempts) {
|
|
116
|
+
const result = await run(attempt.command, attempt.args, env, timeoutMs);
|
|
117
|
+
if (result.missing) {
|
|
118
|
+
missing = `${attempt.command} is not installed`;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const picked = result.stdout.trim();
|
|
122
|
+
if (result.code === 0 && picked)
|
|
123
|
+
return { picked };
|
|
124
|
+
// 1 is "the user closed the window"; 5 is zenity's own timeout.
|
|
125
|
+
if (result.code === 1 || result.code === 5)
|
|
126
|
+
return { cancelled: true };
|
|
127
|
+
return { unavailable: lastLine(result.stderr) || `the ${attempt.command} folder window failed (exit ${result.code})` };
|
|
128
|
+
}
|
|
129
|
+
return { unavailable: missing || "no folder window is installed" };
|
|
130
|
+
}
|
|
131
|
+
// ------------------------------------------------------------------- runner
|
|
132
|
+
const runDialog = (command, args, env, timeoutMs) => new Promise((resolve) => {
|
|
133
|
+
let child;
|
|
134
|
+
try {
|
|
135
|
+
child = spawn(command, args, { env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
resolve({ code: null, stdout: "", stderr: reason(err), missing: isMissing(err) });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
let stdout = "";
|
|
142
|
+
let stderr = "";
|
|
143
|
+
let settled = false;
|
|
144
|
+
let timer = null;
|
|
145
|
+
const finish = (result) => {
|
|
146
|
+
if (settled)
|
|
147
|
+
return;
|
|
148
|
+
settled = true;
|
|
149
|
+
if (timer)
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
resolve(result);
|
|
152
|
+
};
|
|
153
|
+
timer = setTimeout(() => {
|
|
154
|
+
child.kill();
|
|
155
|
+
finish({ code: null, stdout, stderr: stderr || "the folder window was left open for too long", missing: false });
|
|
156
|
+
}, timeoutMs);
|
|
157
|
+
child.stdout?.setEncoding("utf8");
|
|
158
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
|
159
|
+
child.stderr?.setEncoding("utf8");
|
|
160
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
161
|
+
child.on("error", (err) => finish({ code: null, stdout, stderr, missing: isMissing(err) }));
|
|
162
|
+
child.on("close", (code) => finish({ code, stdout, stderr, missing: false }));
|
|
163
|
+
});
|
|
164
|
+
// ------------------------------------------------------------------- inputs
|
|
165
|
+
/** An existing folder to open the window in; the home folder otherwise. */
|
|
166
|
+
function startFolder(raw) {
|
|
167
|
+
const text = String(raw ?? "").trim();
|
|
168
|
+
if (text && path.isAbsolute(text)) {
|
|
169
|
+
try {
|
|
170
|
+
if (fs.statSync(text).isDirectory())
|
|
171
|
+
return text;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// A folder that is gone is simply not offered as a start.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return os.homedir();
|
|
178
|
+
}
|
|
179
|
+
/** The window title is console-authored text, but never trusted into a shell. */
|
|
180
|
+
function dialogTitle(raw) {
|
|
181
|
+
const text = String(raw ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_TITLE_LENGTH);
|
|
182
|
+
return text || DEFAULT_TITLE;
|
|
183
|
+
}
|
|
184
|
+
function trimTrailingSeparator(value) {
|
|
185
|
+
if (!value)
|
|
186
|
+
return "";
|
|
187
|
+
const trimmed = value.replace(/[\\/]+$/, "");
|
|
188
|
+
return trimmed || value; // A filesystem root keeps its separator.
|
|
189
|
+
}
|
|
190
|
+
function lastLine(text) {
|
|
191
|
+
const lines = String(text ?? "")
|
|
192
|
+
.split(/\r?\n/)
|
|
193
|
+
.map((line) => line.trim())
|
|
194
|
+
.filter(Boolean);
|
|
195
|
+
return lines[lines.length - 1] ?? "";
|
|
196
|
+
}
|
|
197
|
+
function isMissing(error) {
|
|
198
|
+
return error?.code === "ENOENT";
|
|
199
|
+
}
|
|
200
|
+
function reason(error) {
|
|
201
|
+
return error instanceof Error ? error.message : String(error);
|
|
202
|
+
}
|
|
203
|
+
// ------------------------------------------------------------------- scripts
|
|
204
|
+
/**
|
|
205
|
+
* C# that finds the dialog window this process just drew and raises it. It is
|
|
206
|
+
* kept apart from the shell interop below because it is compiled a second time,
|
|
207
|
+
* on its own, when the shell dialog itself cannot be created at all.
|
|
208
|
+
*/
|
|
209
|
+
const WINDOW_RAISER = ` private delegate bool WindowProc(IntPtr window, IntPtr parameter);
|
|
210
|
+
|
|
211
|
+
[DllImport("user32.dll")]
|
|
212
|
+
private static extern bool EnumWindows(WindowProc callback, IntPtr parameter);
|
|
213
|
+
[DllImport("user32.dll")]
|
|
214
|
+
private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
|
|
215
|
+
[DllImport("user32.dll")]
|
|
216
|
+
private static extern bool IsWindowVisible(IntPtr window);
|
|
217
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
218
|
+
private static extern int GetClassName(IntPtr window, StringBuilder name, int count);
|
|
219
|
+
[DllImport("user32.dll")]
|
|
220
|
+
private static extern bool ShowWindow(IntPtr window, int command);
|
|
221
|
+
[DllImport("user32.dll")]
|
|
222
|
+
private static extern bool SetForegroundWindow(IntPtr window);
|
|
223
|
+
[DllImport("user32.dll")]
|
|
224
|
+
private static extern bool BringWindowToTop(IntPtr window);
|
|
225
|
+
[DllImport("user32.dll")]
|
|
226
|
+
private static extern IntPtr GetForegroundWindow();
|
|
227
|
+
[DllImport("kernel32.dll")]
|
|
228
|
+
private static extern uint GetCurrentThreadId();
|
|
229
|
+
[DllImport("user32.dll")]
|
|
230
|
+
private static extern bool AttachThreadInput(uint attach, uint attachTo, bool join);
|
|
231
|
+
[DllImport("user32.dll")]
|
|
232
|
+
private static extern bool SetWindowPos(IntPtr window, IntPtr after, int x, int y, int width, int height, uint flags);
|
|
233
|
+
[DllImport("user32.dll")]
|
|
234
|
+
private static extern bool FlashWindowEx(ref FlashInfo info);
|
|
235
|
+
|
|
236
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
237
|
+
private struct FlashInfo
|
|
238
|
+
{
|
|
239
|
+
public uint Size;
|
|
240
|
+
public IntPtr Window;
|
|
241
|
+
public uint Flags;
|
|
242
|
+
public uint Count;
|
|
243
|
+
public uint Timeout;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private const int ShowRestore = 9;
|
|
247
|
+
private const uint PositionNoMove = 0x0002;
|
|
248
|
+
private const uint PositionNoSize = 0x0001;
|
|
249
|
+
private const uint PositionNoActivate = 0x0010;
|
|
250
|
+
private const uint FlashAll = 0x00000003;
|
|
251
|
+
private const string DialogClass = "#32770";
|
|
252
|
+
private static readonly IntPtr TopMost = new IntPtr(-1);
|
|
253
|
+
private static readonly IntPtr NotTopMost = new IntPtr(-2);
|
|
254
|
+
|
|
255
|
+
/// <summary>
|
|
256
|
+
/// The dialog this process has on screen. Only the dialog class counts: the
|
|
257
|
+
/// console this script runs in owns visible windows of its own, and those
|
|
258
|
+
/// must never be raised in the dialog's place.
|
|
259
|
+
/// </summary>
|
|
260
|
+
private static IntPtr DialogWindow()
|
|
261
|
+
{
|
|
262
|
+
int own = Process.GetCurrentProcess().Id;
|
|
263
|
+
IntPtr found = IntPtr.Zero;
|
|
264
|
+
EnumWindows(delegate(IntPtr window, IntPtr parameter)
|
|
265
|
+
{
|
|
266
|
+
uint processId;
|
|
267
|
+
GetWindowThreadProcessId(window, out processId);
|
|
268
|
+
if ((int)processId != own || !IsWindowVisible(window)) return true;
|
|
269
|
+
StringBuilder name = new StringBuilder(32);
|
|
270
|
+
GetClassName(window, name, name.Capacity);
|
|
271
|
+
if (name.ToString() != DialogClass) return true;
|
|
272
|
+
found = window;
|
|
273
|
+
return false;
|
|
274
|
+
}, IntPtr.Zero);
|
|
275
|
+
return found;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// <summary>
|
|
279
|
+
/// The dialog's window, once it is actually on screen; a drawer that has
|
|
280
|
+
/// already finished means there is nothing left to wait for.
|
|
281
|
+
/// </summary>
|
|
282
|
+
private static IntPtr WaitForWindow(Thread drawer, int budgetMs)
|
|
283
|
+
{
|
|
284
|
+
for (int waited = 0; waited < budgetMs; waited += 50)
|
|
285
|
+
{
|
|
286
|
+
IntPtr found = DialogWindow();
|
|
287
|
+
if (found != IntPtr.Zero) return found;
|
|
288
|
+
if (drawer != null && !drawer.IsAlive) return IntPtr.Zero;
|
|
289
|
+
Thread.Sleep(50);
|
|
290
|
+
}
|
|
291
|
+
return IntPtr.Zero;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// <summary>
|
|
295
|
+
/// The window is drawn by a process with no console window of its own, from
|
|
296
|
+
/// the background: Windows leaves it behind whatever the user has in front
|
|
297
|
+
/// and gives it nothing in the taskbar to be found by. Joining the
|
|
298
|
+
/// foreground thread earns the right to raise it, and the topmost hop covers
|
|
299
|
+
/// a foreground window this process may not touch.
|
|
300
|
+
/// </summary>
|
|
301
|
+
public static void Raise(IntPtr window)
|
|
302
|
+
{
|
|
303
|
+
ShowWindow(window, ShowRestore);
|
|
304
|
+
IntPtr foreground = GetForegroundWindow();
|
|
305
|
+
uint foregroundProcess = 0;
|
|
306
|
+
uint foregroundThread = 0;
|
|
307
|
+
if (foreground != IntPtr.Zero) foregroundThread = GetWindowThreadProcessId(foreground, out foregroundProcess);
|
|
308
|
+
uint own = GetCurrentThreadId();
|
|
309
|
+
bool attached = foregroundThread != 0 && foregroundThread != own && AttachThreadInput(own, foregroundThread, true);
|
|
310
|
+
if (!SetForegroundWindow(window))
|
|
311
|
+
{
|
|
312
|
+
BringWindowToTop(window);
|
|
313
|
+
SetForegroundWindow(window);
|
|
314
|
+
}
|
|
315
|
+
SetWindowPos(window, TopMost, 0, 0, 0, 0, PositionNoMove | PositionNoSize | PositionNoActivate);
|
|
316
|
+
SetWindowPos(window, NotTopMost, 0, 0, 0, 0, PositionNoMove | PositionNoSize | PositionNoActivate);
|
|
317
|
+
SetForegroundWindow(window);
|
|
318
|
+
if (attached) AttachThreadInput(own, foregroundThread, false);
|
|
319
|
+
|
|
320
|
+
// Focus cannot always move to a background process; a flash still tells
|
|
321
|
+
// the user where the window is.
|
|
322
|
+
FlashInfo flash = new FlashInfo();
|
|
323
|
+
flash.Size = (uint)Marshal.SizeOf(typeof(FlashInfo));
|
|
324
|
+
flash.Window = window;
|
|
325
|
+
flash.Flags = FlashAll;
|
|
326
|
+
flash.Count = 3;
|
|
327
|
+
FlashWindowEx(ref flash);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/// <summary>
|
|
331
|
+
/// Raises the window a blocking ShowDialog is still drawing: the wait and the
|
|
332
|
+
/// raise run on a thread of their own, and a host that cannot do it keeps a
|
|
333
|
+
/// dialog that is merely harder to find.
|
|
334
|
+
/// </summary>
|
|
335
|
+
public static void RaiseWhenShown(int budgetMs)
|
|
336
|
+
{
|
|
337
|
+
Thread raiser = new Thread(delegate()
|
|
338
|
+
{
|
|
339
|
+
IntPtr window = WaitForWindow(null, budgetMs);
|
|
340
|
+
if (window == IntPtr.Zero) return;
|
|
341
|
+
try { Raise(window); }
|
|
342
|
+
catch (Exception) { }
|
|
343
|
+
});
|
|
344
|
+
raiser.IsBackground = true;
|
|
345
|
+
raiser.Start();
|
|
346
|
+
}
|
|
347
|
+
`;
|
|
348
|
+
/**
|
|
349
|
+
* The COM interop is written out by hand because .NET Framework's own
|
|
350
|
+
* `FolderBrowserDialog` is the old tree window: `IFileOpenDialog` with
|
|
351
|
+
* `FOS_PICKFOLDERS` is the Explorer window Windows 10 and 11 users expect.
|
|
352
|
+
* The WinForms dialog stays as a fallback for a host that refuses the interop.
|
|
353
|
+
*
|
|
354
|
+
* The script is kept at column 0 so the PowerShell here-strings stay intact.
|
|
355
|
+
*/
|
|
356
|
+
const WINDOWS_DIALOG = `$ErrorActionPreference = 'Stop'
|
|
357
|
+
|
|
358
|
+
$source = @'
|
|
359
|
+
using System;
|
|
360
|
+
using System.Diagnostics;
|
|
361
|
+
using System.Runtime.InteropServices;
|
|
362
|
+
using System.Text;
|
|
363
|
+
using System.Threading;
|
|
364
|
+
|
|
365
|
+
public static class NeutrinoFolderWindow
|
|
366
|
+
{
|
|
367
|
+
[ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")]
|
|
368
|
+
private class FileOpenDialog { }
|
|
369
|
+
|
|
370
|
+
// The class only answers QueryInterface for IFileOpenDialog's own iid:
|
|
371
|
+
// asking for IFileDialog's iid is refused with E_NOINTERFACE on current
|
|
372
|
+
// Windows builds. Every IFileDialog member comes first in this vtable, so
|
|
373
|
+
// the whole dialog - Show, SetOptions, GetResult - is reachable through it,
|
|
374
|
+
// and the window is the Explorer folder picker rather than the old tree.
|
|
375
|
+
[ComImport, Guid("d57c7288-d4ad-4768-be02-9d969532d960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
376
|
+
private interface IFileOpenDialog
|
|
377
|
+
{
|
|
378
|
+
[PreserveSig] int Show(IntPtr parent);
|
|
379
|
+
void SetFileTypes(uint count, IntPtr filter);
|
|
380
|
+
void SetFileTypeIndex(uint index);
|
|
381
|
+
void GetFileTypeIndex(out uint index);
|
|
382
|
+
void Advise(IntPtr events, out uint cookie);
|
|
383
|
+
void Unadvise(uint cookie);
|
|
384
|
+
void SetOptions(uint options);
|
|
385
|
+
void GetOptions(out uint options);
|
|
386
|
+
void SetDefaultFolder(IShellItem folder);
|
|
387
|
+
void SetFolder(IShellItem folder);
|
|
388
|
+
void GetFolder(out IShellItem folder);
|
|
389
|
+
void GetCurrentSelection(out IShellItem item);
|
|
390
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name);
|
|
391
|
+
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name);
|
|
392
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
|
|
393
|
+
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
|
|
394
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string label);
|
|
395
|
+
void GetResult(out IShellItem item);
|
|
396
|
+
void AddPlace(IShellItem item, int place);
|
|
397
|
+
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string extension);
|
|
398
|
+
void Close(int hr);
|
|
399
|
+
void SetClientGuid(ref Guid guid);
|
|
400
|
+
void ClearClientData();
|
|
401
|
+
void SetFilter(IntPtr filter);
|
|
402
|
+
void GetResults(out IntPtr items);
|
|
403
|
+
void GetSelectedItems(out IntPtr items);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
[ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
407
|
+
private interface IShellItem
|
|
408
|
+
{
|
|
409
|
+
void BindToHandler(IntPtr bindContext, ref Guid handler, ref Guid interfaceId, out IntPtr result);
|
|
410
|
+
void GetParent(out IShellItem parent);
|
|
411
|
+
void GetDisplayName(uint form, [MarshalAs(UnmanagedType.LPWStr)] out string name);
|
|
412
|
+
void GetAttributes(uint mask, out uint attributes);
|
|
413
|
+
void Compare(IShellItem other, uint hint, out int order);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
|
|
417
|
+
private static extern void SHCreateItemFromParsingName(
|
|
418
|
+
[MarshalAs(UnmanagedType.LPWStr)] string path,
|
|
419
|
+
IntPtr bindContext,
|
|
420
|
+
[MarshalAs(UnmanagedType.LPStruct)] Guid interfaceId,
|
|
421
|
+
[MarshalAs(UnmanagedType.Interface)] out IShellItem item);
|
|
422
|
+
|
|
423
|
+
private const uint PickFolders = 0x00000020;
|
|
424
|
+
private const uint ForceFileSystem = 0x00000040;
|
|
425
|
+
private const uint NoChangeDir = 0x00000008;
|
|
426
|
+
private const uint FileSystemPath = 0x80058000;
|
|
427
|
+
|
|
428
|
+
${WINDOW_RAISER}
|
|
429
|
+
/// <summary>The chosen folder, or null when the window was closed.</summary>
|
|
430
|
+
public static string Pick(string start, string title)
|
|
431
|
+
{
|
|
432
|
+
string picked = null;
|
|
433
|
+
Exception failure = null;
|
|
434
|
+
// A dialog belongs to the thread that shows it, so it is created and
|
|
435
|
+
// drawn there; this thread only has to raise the finished window.
|
|
436
|
+
Thread worker = new Thread(delegate()
|
|
437
|
+
{
|
|
438
|
+
try { picked = Show(start, title); }
|
|
439
|
+
catch (Exception error) { failure = error; }
|
|
440
|
+
});
|
|
441
|
+
worker.SetApartmentState(ApartmentState.STA);
|
|
442
|
+
worker.IsBackground = true;
|
|
443
|
+
worker.Start();
|
|
444
|
+
|
|
445
|
+
IntPtr window = WaitForWindow(worker, 20000);
|
|
446
|
+
if (window != IntPtr.Zero)
|
|
447
|
+
{
|
|
448
|
+
// Raising the window is a courtesy: if it cannot be done the dialog
|
|
449
|
+
// is still usable, so it must never fail, or cancel, the pick.
|
|
450
|
+
try { Raise(window); }
|
|
451
|
+
catch (Exception) { }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
worker.Join();
|
|
455
|
+
if (failure != null) throw failure;
|
|
456
|
+
return picked;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private static string Show(string start, string title)
|
|
460
|
+
{
|
|
461
|
+
IFileOpenDialog dialog = (IFileOpenDialog)new FileOpenDialog();
|
|
462
|
+
uint options;
|
|
463
|
+
dialog.GetOptions(out options);
|
|
464
|
+
dialog.SetOptions(options | PickFolders | ForceFileSystem | NoChangeDir);
|
|
465
|
+
if (!string.IsNullOrEmpty(title)) dialog.SetTitle(title);
|
|
466
|
+
if (!string.IsNullOrEmpty(start))
|
|
467
|
+
{
|
|
468
|
+
try
|
|
469
|
+
{
|
|
470
|
+
IShellItem folder;
|
|
471
|
+
SHCreateItemFromParsingName(start, IntPtr.Zero, new Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), out folder);
|
|
472
|
+
dialog.SetFolder(folder);
|
|
473
|
+
}
|
|
474
|
+
catch (Exception)
|
|
475
|
+
{
|
|
476
|
+
// A start folder that no longer exists must not fail the dialog.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
int result = dialog.Show(IntPtr.Zero);
|
|
480
|
+
if (result != 0) return null;
|
|
481
|
+
IShellItem chosen;
|
|
482
|
+
dialog.GetResult(out chosen);
|
|
483
|
+
string path;
|
|
484
|
+
chosen.GetDisplayName(FileSystemPath, out path);
|
|
485
|
+
return path;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
'@
|
|
489
|
+
|
|
490
|
+
# Compiled only when the interop above is refused: it holds no COM at all, so a
|
|
491
|
+
# host that will not take the shell dialog can still raise the older window.
|
|
492
|
+
$raise = @'
|
|
493
|
+
using System;
|
|
494
|
+
using System.Diagnostics;
|
|
495
|
+
using System.Runtime.InteropServices;
|
|
496
|
+
using System.Text;
|
|
497
|
+
using System.Threading;
|
|
498
|
+
|
|
499
|
+
public static class NeutrinoFallbackRaise
|
|
500
|
+
{
|
|
501
|
+
${WINDOW_RAISER}
|
|
502
|
+
}
|
|
503
|
+
'@
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
Add-Type -TypeDefinition $source -Language CSharp | Out-Null
|
|
507
|
+
$picked = [NeutrinoFolderWindow]::Pick($env:NEUTRINO_PICKER_START, $env:NEUTRINO_PICKER_TITLE)
|
|
508
|
+
if ($picked) { [Console]::Out.Write($picked) }
|
|
509
|
+
} catch {
|
|
510
|
+
# A locked-down host may refuse the interop; the plain WinForms dialog is the
|
|
511
|
+
# same choice in an older skin and is always available.
|
|
512
|
+
try {
|
|
513
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
514
|
+
$window = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
515
|
+
if ($env:NEUTRINO_PICKER_TITLE) { $window.Description = $env:NEUTRINO_PICKER_TITLE }
|
|
516
|
+
if ($env:NEUTRINO_PICKER_START) { $window.SelectedPath = $env:NEUTRINO_PICKER_START }
|
|
517
|
+
# ShowDialog blocks this thread, so the window is raised from another one;
|
|
518
|
+
# a host that will not compile the raiser still shows a usable window.
|
|
519
|
+
try {
|
|
520
|
+
Add-Type -TypeDefinition $raise -Language CSharp | Out-Null
|
|
521
|
+
[NeutrinoFallbackRaise]::RaiseWhenShown(20000)
|
|
522
|
+
} catch { }
|
|
523
|
+
if ($window.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($window.SelectedPath) }
|
|
524
|
+
} catch {
|
|
525
|
+
[Console]::Error.Write($_.Exception.Message)
|
|
526
|
+
exit 1
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
exit 0
|
|
530
|
+
`;
|
|
531
|
+
/** `choose folder` returns a path ending in a separator; the caller strips it. */
|
|
532
|
+
const MAC_DIALOG = [
|
|
533
|
+
"on run argv",
|
|
534
|
+
"set promptText to item 1 of argv",
|
|
535
|
+
"set startPath to item 2 of argv",
|
|
536
|
+
'tell application "System Events" to activate',
|
|
537
|
+
'if startPath is "" then',
|
|
538
|
+
"set chosen to choose folder with prompt promptText",
|
|
539
|
+
"else",
|
|
540
|
+
"set chosen to choose folder with prompt promptText default location (POSIX file startPath)",
|
|
541
|
+
"end if",
|
|
542
|
+
"return POSIX path of chosen",
|
|
543
|
+
"end run",
|
|
544
|
+
].join("\n");
|