bridgerator 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +76 -14
  2. package/assets/bridgerator.ico +0 -0
  3. package/assets/icon-16.png +0 -0
  4. package/assets/icon-20.png +0 -0
  5. package/assets/icon-24.png +0 -0
  6. package/assets/icon-256.png +0 -0
  7. package/assets/icon-32.png +0 -0
  8. package/assets/icon-48.png +0 -0
  9. package/assets/icon-64.png +0 -0
  10. package/assets/logo.svg +1 -0
  11. package/install.ps1 +455 -56
  12. package/npm-shrinkwrap.json +8621 -0
  13. package/package.json +55 -49
  14. package/recipes/backup-folder.json +31 -0
  15. package/recipes/clean-downloads.json +48 -0
  16. package/recipes/disk-report.json +66 -0
  17. package/recipes/sort-folder.json +49 -0
  18. package/recipes/update-apps.json +25 -0
  19. package/src/ask-lib.ps1 +230 -0
  20. package/src/ask.mjs +51 -0
  21. package/src/ask.ps1 +17 -0
  22. package/src/automations.mjs +673 -0
  23. package/src/batch.mjs +221 -0
  24. package/src/bridge.mjs +720 -29
  25. package/src/cli.mjs +529 -74
  26. package/src/confirm.mjs +418 -0
  27. package/src/dc-config-hooks.mjs +18 -8
  28. package/src/dc-isolate.mjs +5 -0
  29. package/src/delegate.mjs +555 -0
  30. package/src/docs-tool.mjs +494 -0
  31. package/src/doctor.mjs +200 -0
  32. package/src/events.mjs +21 -0
  33. package/src/extra-tools.mjs +122 -0
  34. package/src/guard.mjs +34 -0
  35. package/src/notice.mjs +84 -23
  36. package/src/pair.mjs +27 -4
  37. package/src/pc-check.mjs +112 -0
  38. package/src/pc-check.ps1 +103 -0
  39. package/src/pc-text.mjs +94 -0
  40. package/src/pdf-font.mjs +245 -0
  41. package/src/photos-tool.mjs +187 -0
  42. package/src/photos.ps1 +145 -0
  43. package/src/plan.mjs +90 -0
  44. package/src/policy.mjs +293 -124
  45. package/src/queue.mjs +194 -0
  46. package/src/recent.mjs +48 -0
  47. package/src/recipes.mjs +108 -0
  48. package/src/recycle.mjs +185 -0
  49. package/src/recycle.ps1 +110 -0
  50. package/src/screen-tool.mjs +185 -0
  51. package/src/screen.ps1 +285 -0
  52. package/src/search-tool.mjs +189 -0
  53. package/src/search.ps1 +129 -0
  54. package/src/settings-tool.mjs +186 -0
  55. package/src/setup-tool.mjs +129 -0
  56. package/src/software-tool.mjs +185 -0
  57. package/src/telemetry.mjs +86 -0
  58. package/src/tool-lib.mjs +177 -0
  59. package/src/tool-text.mjs +45 -0
  60. package/src/tray.mjs +97 -0
  61. package/src/tray.ps1 +359 -0
  62. package/src/undo.mjs +313 -0
  63. package/src/win.mjs +17 -0
  64. package/ui/app.css +388 -0
  65. package/ui/app.js +868 -0
  66. package/ui/i18n/en.json +598 -0
  67. package/ui/i18n/ru.json +598 -0
  68. package/ui/index.html +26 -0
  69. package/vendor/webview2/LICENSE.txt +27 -0
  70. package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
  71. package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
  72. package/vendor/webview2/NOTICE.txt +85 -0
  73. package/vendor/webview2/SHA256SUMS.txt +4 -0
  74. package/vendor/webview2/SOURCE.txt +1 -0
  75. package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
  76. package/vendor/webview2/x64/WebView2Loader.dll +0 -0
@@ -0,0 +1,185 @@
1
+ // LOGIC: see critical-logic/bridge.md ("Software (winget)"). Programs through the Windows Package
2
+ // Manager (winget, part of Windows 10/11 "App Installer"): search, installed list, available updates,
3
+ // install, upgrade. winget is started by its absolute path (a planted winget.exe on the PATH never
4
+ // runs), never through a shell, with --disable-interactivity; install / upgrade run --silent.
5
+ // Agreements are accepted ONLY when the call says accept_agreements: true (the AI asks the person
6
+ // first). Install / upgrade = confirmation category "install" (asks only when the person turned
7
+ // confirmations on) and are refused while the terminal is off (access presets Read only / Files).
8
+ // Tables are read by column positions from the dashes line under the header (any language), the
9
+ // result by winget's exit code (HRESULT), never by message text.
10
+ import fs from 'node:fs';
11
+ import { spawn } from 'node:child_process';
12
+ import { stripOurs } from './ask.mjs';
13
+ import { WINGET } from './win.mjs';
14
+ import { ToolError, fail, json, needAction, str } from './tool-lib.mjs';
15
+
16
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$/; // winget ids: Vendor.App, 9NBLGGH4NNS1 (Store)
17
+ const SOURCES = ['winget', 'msstore'];
18
+ const MAX_ROWS = 200;
19
+
20
+ // winget runs in the background, so Windows often shows its administrator prompt only as a flashing
21
+ // taskbar button (a background program can't bring it to the front): the person must be told to look.
22
+ export const UAC_NOTE = 'Windows may show its administrator prompt only as a flashing button in the taskbar — ask the person to click it and choose Yes.';
23
+ // HRESULT exit codes (winget returnCodes.md) → plain words + what to do.
24
+ const CODES = {
25
+ 0x8A150014: ['No program matches.', 'Search with other words (action "search").'],
26
+ 0x8A150016: ['Several programs match.', 'Use the exact id from action "search".'],
27
+ 0x8A150010: ['No installer of this program fits this PC.', ''],
28
+ 0x8A150011: ['The downloaded installer does not match its published checksum, so it was not run.', 'Try again later.'],
29
+ 0x8A150008: ['The download failed.', 'Check the internet connection and try again.'],
30
+ 0x8A150019: ['This needs administrator rights.', 'Ask the person to run it from an administrator terminal.'],
31
+ 0x8A15002B: ['No update is available for it.', ''],
32
+ 0x8A15002C: ['Some updates failed.', 'Check the list with action "updates" and update them one by one.'],
33
+ 0x8A15003A: ['A company policy blocks winget on this PC.', 'Ask the PC administrator.'],
34
+ 0x8A150041: ['The program\'s license terms were not accepted.', 'Ask the person; if they agree, call again with accept_agreements: true.'],
35
+ 0x8A150046: ['The program source\'s terms were not accepted yet.', 'Ask the person; if they agree, call again with accept_agreements: true.'],
36
+ 0x8A15004F: ['The available version is not newer than the installed one.', ''],
37
+ 0x8A150101: ['The program is running.', 'Close it and try again.'],
38
+ 0x8A150102: ['Another installation is in progress.', 'Try again in a few minutes.'],
39
+ 0x8A150103: ['A file of the program is in use.', 'Close the program and try again.'],
40
+ 0x8A150105: ['The disk is full.', 'Free some space and try again.'],
41
+ 0x8A150107: ['The installer needs the internet.', 'Check the connection.'],
42
+ 0x8A150109: ['Installed — restart the PC to finish.', ''],
43
+ 0x8A15010A: ['Installation failed: restart the PC, then try again.', ''],
44
+ 0x8A15010B: ['The PC will restart to finish the installation.', ''],
45
+ 0x8A15010C: ['The installation was cancelled (for example "No" in the Windows administrator prompt).', `Ask the person to allow it when Windows asks. ${UAC_NOTE}`],
46
+ 0x8A15010D: ['It is already installed.', 'Use action "upgrade" to update it.'],
47
+ 0x8A15010E: ['A newer version is already installed.', ''],
48
+ // Seen on a lab PC after "No" in the administrator prompt (installer exit 1625): said as not approved.
49
+ 0x8A15010F: ['The installation was cancelled or not approved (for example "No" in the Windows administrator prompt, or a policy of this PC).', `Ask the person to try again and allow it when Windows asks. ${UAC_NOTE}`],
50
+ 0x8A150113: ['This program does not support this Windows version.', ''],
51
+ 0x8A150115: ['The program\'s installer reported an error.', 'Try again, or install it from the vendor\'s site.'],
52
+ };
53
+ const OK_CODES = new Set([0, 0x8A150109]);
54
+
55
+ /** Runs winget (win.WINGET) → { code (unsigned HRESULT), out }. ctx.runWinget (tests) replaces it. */
56
+ // winget.exe in WindowsApps is an App Execution Alias: stat() fails (EACCES) so fs.existsSync says false; lstat sees it.
57
+ export const aliasExists = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } };
58
+ export function runWinget(args, { timeout = 120000, exists = aliasExists } = {}) {
59
+ if (process.platform !== 'win32') return Promise.reject(new ToolError('NOT_WINDOWS', 'This works only on Windows.'));
60
+ if (!exists(WINGET)) return Promise.reject(new ToolError('NO_WINGET', 'winget (Windows Package Manager) is not available on this PC.', 'It comes with "App Installer" from the Microsoft Store; Windows 10 1809+ and Windows 11 have it after updates.'));
61
+ return new Promise((resolve, reject) => {
62
+ const child = spawn(WINGET, args, { windowsHide: true, env: stripOurs(process.env), stdio: ['ignore', 'pipe', 'pipe'] });
63
+ const chunks = [];
64
+ let size = 0;
65
+ const timer = setTimeout(() => { child.kill(); reject(new ToolError('TIMEOUT', 'winget did not finish in time.', `An installer may be waiting for an answer on the PC screen; ask the person to look. ${UAC_NOTE}`)); }, timeout);
66
+ const take = (b) => { size += b.length; if (size < 8 * 1024 * 1024) chunks.push(b); };
67
+ child.stdout.on('data', take);
68
+ child.stderr.on('data', take);
69
+ child.on('error', (e) => { clearTimeout(timer); reject(new ToolError('NO_WINGET', `winget did not start (${e.code || e.message}).`, 'Update "App Installer" from the Microsoft Store.')); });
70
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code: code >>> 0, out: Buffer.concat(chunks).toString('utf8') }); });
71
+ });
72
+ }
73
+
74
+ /** Output lines without progress spinners / bars (text after the last bare \r of a line). */
75
+ export function cleanLines(out, { blank = false } = {}) {
76
+ return String(out).replace(/\r\n/g, '\n').split('\n')
77
+ .map((l) => l.slice(l.lastIndexOf('\r') + 1).replace(/[▀-▟]+/g, '').replace(/\s+$/, ''))
78
+ .filter((l) => (blank && !l) || (l && !/^\s*[-\\|/]\s*$/.test(l)));
79
+ }
80
+
81
+ /**
82
+ * The first table of winget output → rows of cells. Columns start where the header words start;
83
+ * the dashes line marks the header. Header labels are localized and only give positions.
84
+ * → { cols: number, rows: [[cell…]] }
85
+ */
86
+ export function parseTable(out) {
87
+ const lines = cleanLines(out, { blank: true });
88
+ const dash = lines.findIndex((l) => /^-{10,}$/.test(l.trim()));
89
+ if (dash < 1) return { cols: 0, rows: [] };
90
+ const header = lines[dash - 1];
91
+ const starts = [];
92
+ for (const m of header.matchAll(/\S+/g)) starts.push(m.index);
93
+ const rows = [];
94
+ for (let i = dash + 1; i < lines.length && rows.length < MAX_ROWS; i++) {
95
+ const l = lines[i];
96
+ // The table ends at a blank line, a dashes line, or the header of a second table (its dashes follow).
97
+ if (!l.trim() || /^-{10,}$/.test(l.trim()) || /^-{10,}$/.test((lines[i + 1] || '').trim())) break;
98
+ rows.push(starts.map((s, j) => l.slice(s, starts[j + 1] ?? undefined).trim()));
99
+ }
100
+ return { cols: starts.length, rows };
101
+ }
102
+
103
+ // Column order is fixed by winget: search Name Id Version [Match] Source; list Name Id Version
104
+ // [Available] Source; upgrade Name Id Version Available Source.
105
+ function rowsOf(kind, { cols, rows }) {
106
+ return rows.map((r) => {
107
+ const o = { name: r[0], id: r[1], version: r[2] };
108
+ if (cols >= 5) { if (kind === 'search') { if (r[3]) o.match = r[3]; } else if (r[3]) o.available = r[3]; o.source = r[4] || undefined; }
109
+ else if (cols === 4) { if (kind === 'updates') o.available = r[3]; else o.source = r[3] || undefined; }
110
+ return o;
111
+ }).filter((o) => /^\S+$/.test(o.id || '') && o.version); // footer lines ("2 upgrades available.") have no id
112
+ }
113
+
114
+ function explain(code, out) {
115
+ if (OK_CODES.has(code)) return null;
116
+ const known = CODES[code];
117
+ const hex = `0x${code.toString(16).toUpperCase()}`;
118
+ const tail = cleanLines(out).filter((l) => !/^-{3,}/.test(l)).slice(-3).join(' | ').slice(0, 300);
119
+ return known ? new ToolError(`WINGET_${hex}`, known[0], known[1]) : new ToolError(`WINGET_${hex}`, `winget stopped with code ${hex}.${tail ? ` Last output: ${tail}` : ''}`, 'Try again; if it repeats, the person can run the same winget command in a terminal to see more.');
120
+ }
121
+
122
+ const base = (args) => ['--disable-interactivity', ...(args.accept_agreements === true ? ['--accept-source-agreements'] : [])];
123
+ const sourceArg = (args) => { const s = str(args, 'source', { max: 20 }); if (s && !SOURCES.includes(s)) fail('BAD_ARGS', `source must be ${SOURCES.join(' or ')}.`); return s ? ['--source', s] : []; };
124
+ const idArg = (args, required) => {
125
+ const id = str(args, 'id', { required, max: 128 });
126
+ if (id && !ID_RE.test(id)) fail('BAD_ARGS', 'id must be a winget package id like "7zip.7zip" (from action "search").');
127
+ return id;
128
+ };
129
+
130
+ export const defs = [{
131
+ name: 'software',
132
+ title: 'Programs (winget)',
133
+ description: 'Programs on this PC through the Windows Package Manager (winget). search: find programs by name; list: installed programs; updates: programs with a newer version; install / upgrade: install or update one program by its id (silent; upgrade with all: true updates everything). Windows may still show its administrator prompt on the PC for some installers (often only as a flashing taskbar button). Program and source license terms are accepted only with accept_agreements: true, after the person agreed.',
134
+ inputSchema: {
135
+ type: 'object', additionalProperties: false, required: ['action'],
136
+ properties: {
137
+ action: { type: 'string', enum: ['search', 'list', 'updates', 'install', 'upgrade'] },
138
+ query: { type: 'string', maxLength: 100, description: 'search / list: words to look for' },
139
+ id: { type: 'string', maxLength: 128, description: 'install / upgrade: exact package id, e.g. "7zip.7zip"' },
140
+ all: { type: 'boolean', description: 'upgrade: update every program that has an update' },
141
+ version: { type: 'string', maxLength: 40, description: 'install: a specific version' },
142
+ scope: { type: 'string', enum: ['user', 'machine'], description: 'install: for this user only, or for all users (needs administrator rights)' },
143
+ source: { type: 'string', enum: SOURCES },
144
+ accept_agreements: { type: 'boolean', description: 'Accept the program\'s and the source\'s license terms (only after the person agreed). Default false.' },
145
+ },
146
+ },
147
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
148
+ }];
149
+
150
+ export async function handle(name, args, ctx) {
151
+ const action = needAction(args, defs[0].inputSchema.properties.action.enum);
152
+ const run = ctx.runWinget || runWinget;
153
+ if (action === 'search' || action === 'list' || action === 'updates') {
154
+ const q = str(args, 'query', { required: action === 'search', max: 100 });
155
+ if (q && /[\u0000-\u001f"]/.test(q)) fail('BAD_ARGS', 'query must be plain text.');
156
+ const argv = action === 'search' ? ['search', '--query', q, '--count', '50']
157
+ : action === 'list' ? ['list', ...(q ? ['--query', q] : [])]
158
+ : ['upgrade'];
159
+ const r = await run([...argv, ...sourceArg(args), ...base(args)], { timeout: 120000 });
160
+ if (r.code === 0x8A150014) return json({ action, found: 0, programs: [] });
161
+ const err = explain(r.code, r.out);
162
+ if (err) throw err;
163
+ const programs = rowsOf(action, parseTable(r.out));
164
+ return json({ action, found: programs.length, programs, ...(programs.length >= MAX_ROWS ? { note: `first ${MAX_ROWS} shown` } : {}) });
165
+ }
166
+ // install / upgrade: like a terminal command — off with the terminal; asks when confirmations are on.
167
+ if ((ctx.cfg?.disabledTools || []).includes('start_process')) fail('TERMINAL_OFF', 'Installing programs is off on this PC (access preset Read only or Files).', 'The person can switch to Full in Bridgerator → Safety, or you can ask with bridgerator_settings.');
168
+ const id = idArg(args, !(action === 'upgrade' && args.all === true));
169
+ if (action === 'upgrade' && args.all === true && id) fail('BAD_ARGS', 'Give either id or all: true, not both.');
170
+ const version = str(args, 'version', { max: 40 });
171
+ if (version && !/^[A-Za-z0-9.+_-]+$/.test(version)) fail('BAD_ARGS', 'version must look like 1.2.3.');
172
+ // Fails closed: without the confirmation gate nothing is installed.
173
+ if (typeof ctx.gate?.gate !== 'function') fail('NO_GATE', 'Installing programs is not available here.');
174
+ const stop = await ctx.gate.gate('software', { ...args, action });
175
+ if (stop) return stop;
176
+ const argv = [action, ...(id ? ['--id', id, '--exact'] : ['--all']),
177
+ ...(version && action === 'install' ? ['--version', version] : []),
178
+ ...(args.scope && action === 'install' ? ['--scope', args.scope === 'machine' ? 'machine' : 'user'] : []),
179
+ '--silent', ...sourceArg(args), ...base(args), ...(args.accept_agreements === true ? ['--accept-package-agreements'] : [])];
180
+ ctx.log?.(`[bridgerator] software ${action} ${id || '--all'}`);
181
+ const r = await run(argv, { timeout: 30 * 60000 });
182
+ const err = explain(r.code, r.out);
183
+ if (err) throw err;
184
+ return json({ action, id: id || 'all', done: true, ...(r.code === 0x8A150109 ? { restartNeeded: true } : {}), output: cleanLines(r.out).filter((l) => !/^-{3,}/.test(l)).slice(-6) });
185
+ }
@@ -0,0 +1,86 @@
1
+ // Anonymous usage counts to Bridgerator (disclosed on the privacy page; off with
2
+ // `bridgerator.cmd telemetry off`, "telemetryEnabled": false in config.json, or install -NoTelemetry).
3
+ // Only these fields: random install id, bridge version, Windows version, OS language, Node.js
4
+ // version, and in the daily heartbeat per-tool call / error counts. Never paths, file names,
5
+ // commands, arguments, outputs, e-mail. HTTPS only; any failure is silent.
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import { createRequire } from 'node:module';
9
+ import { writeAtomic } from './confirm.mjs';
10
+
11
+ export const TELEMETRY_URL = 'https://bridgerator.recondeck.com/t';
12
+ export const VERSION = createRequire(import.meta.url)('../package.json').version;
13
+ const DAY = 24 * 3600 * 1000;
14
+ const READ_FAILS_MAX = 3; // hourly ticks
15
+
16
+ export const enabled = (cfg) => !!cfg?.installId && cfg.telemetryEnabled !== false;
17
+
18
+ export function payload(cfg, event, extra = {}) {
19
+ let lang = '';
20
+ try { lang = Intl.DateTimeFormat().resolvedOptions().locale; } catch { /* none */ }
21
+ return { event, id: cfg.installId, version: VERSION, os: `${os.platform()} ${os.release()}`, lang, node: process.version, ...extra };
22
+ }
23
+
24
+ export async function send(cfg, event, extra, { fetch: f = globalThis.fetch } = {}) {
25
+ if (!enabled(cfg)) return;
26
+ try {
27
+ await f(TELEMETRY_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload(cfg, event, extra)), signal: AbortSignal.timeout(10000) });
28
+ } catch { /* offline: nothing */ }
29
+ }
30
+
31
+ // Per-tool counters for the heartbeat. `known` = the engine's tool names: a name the relay made up
32
+ // is never counted (only our own fixed tool names leave the PC). State survives restarts in `file`.
33
+ export function usage(cfg, known, file, { now = Date.now, sendFn = send } = {}) {
34
+ const fresh = () => ({ since: now(), calls: {}, errors: {} });
35
+ // Only a missing file (ENOENT) or an unparsable / wrong-shape one = fresh (rewritten at the next save),
36
+ // so one bad usage.json never stops counting for good. Any other read error (a lock of antivirus /
37
+ // backup, EIO, EMFILE, EISDIR...) throws — the caller keeps its in-memory state and retries next tick;
38
+ // after READ_FAILS_MAX failed ticks in a row it goes on from memory (rewrites the file, the heartbeat
39
+ // goes), so a permanently unreadable file never stops telemetry. Only since / calls / errors are kept;
40
+ // a counter that is not a non-negative safe integer is dropped.
41
+ const load = () => {
42
+ let txt;
43
+ try { txt = fs.readFileSync(file, 'utf8'); } catch (e) { if (e?.code === 'ENOENT') return fresh(); throw e; }
44
+ let j; try { j = JSON.parse(txt); } catch { return fresh(); }
45
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return fresh();
46
+ const obj = (o) => Object.fromEntries(Object.entries(o && typeof o === 'object' && !Array.isArray(o) ? o : {}).filter(([, n]) => Number.isSafeInteger(n) && n >= 0));
47
+ return { since: Number.isFinite(j.since) ? j.since : now(), calls: obj(j.calls), errors: obj(j.errors) };
48
+ };
49
+ let st; try { st = load(); } catch { st = fresh(); }
50
+ // Counted since the last save: added to what is in the file then (another bridge process may have
51
+ // saved meanwhile — no last-writer-wins), written atomically (temp + rename).
52
+ let delta = { calls: {}, errors: {} };
53
+ const add = (to, from) => { for (const [k, n] of Object.entries(from)) to[k] = (to[k] || 0) + n; };
54
+ const write = () => { try { writeAtomic(file, JSON.stringify(st)); } catch { /* read-only */ } };
55
+ let fails = 0;
56
+ const save = () => {
57
+ let disk;
58
+ try { disk = load(); fails = 0; } catch { // locked now: keep st + delta, retry next tick
59
+ if (++fails < READ_FAILS_MAX) return false;
60
+ fails = 0; delta = { calls: {}, errors: {} }; write(); return true; // still unreadable: go on from memory
61
+ }
62
+
63
+ add(disk.calls, delta.calls); add(disk.errors, delta.errors);
64
+ delta = { calls: {}, errors: {} };
65
+ st = disk;
66
+ write();
67
+ return true;
68
+ };
69
+ return {
70
+ count(name, isError) {
71
+ if (!known.has(name)) return;
72
+ st.calls[name] = (st.calls[name] || 0) + 1;
73
+ delta.calls[name] = (delta.calls[name] || 0) + 1;
74
+ if (isError) { st.errors[name] = (st.errors[name] || 0) + 1; delta.errors[name] = (delta.errors[name] || 0) + 1; }
75
+ },
76
+ async tick() {
77
+ if (!save()) return;
78
+ if (now() - st.since < DAY) return;
79
+ const { calls, errors } = st;
80
+ st = fresh();
81
+ write();
82
+ await sendFn(cfg, 'heartbeat', { calls, errors });
83
+ },
84
+ state: () => st,
85
+ };
86
+ }
@@ -0,0 +1,177 @@
1
+ // LOGIC: see critical-logic/bridge.md ("Extra tools"). Shared helpers of the extra tools:
2
+ // plain-words errors (stable code + what to do, never a stack), the folder guard (same rules as the
3
+ // engine's file tools: allowed list, excluded, read-only, UNC) and the PowerShell runner.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { POWERSHELL, TASKKILL, psArgs } from './win.mjs';
8
+ import { runFilesPs, stripOurs } from './ask.mjs';
9
+ import { norm, realish } from './policy.mjs';
10
+
11
+ export class ToolError extends Error {
12
+ /** code: stable UPPER_CASE id; hint: what the person / AI can do about it. */
13
+ constructor(code, message, hint = '') { super(message); this.code = code; this.hint = hint; }
14
+ }
15
+
16
+ // Node / Windows error codes → plain words (the AI shows them to the person).
17
+ const NODE_ERRORS = {
18
+ ENOENT: ['File or folder not found.', 'Check the path (list the folder first).'],
19
+ EACCES: ['Windows denied access to this file or folder.', 'The file may belong to another user or need administrator rights.'],
20
+ EPERM: ['Windows did not allow this change.', 'The file may be read-only, open in another program, or protected.'],
21
+ EBUSY: ['The file is open in another program.', 'Close it there and try again.'],
22
+ EEXIST: ['A file with this name already exists.', 'Choose another name or allow overwriting.'],
23
+ ENOSPC: ['The disk is full.', 'Free some space and try again.'],
24
+ EISDIR: ['This is a folder, not a file.', ''],
25
+ ENOTDIR: ['A part of the path is a file, not a folder.', ''],
26
+ ETIMEDOUT: ['The operation took too long and was stopped.', 'Try a smaller task or try again later.'],
27
+ };
28
+ const clean = (s) => fromClixml(String(s ?? '')).replace(/\r?\n\s+at\s.*$/s, '').replace(/[\u0000-\u0008\u000b-\u001f]/g, ' ').trim().slice(0, 400);
29
+
30
+ // PowerShell writes errors of a non-interactive run as CLIXML: keep only the error text.
31
+ export function fromClixml(s) {
32
+ if (!s.startsWith('#< CLIXML')) return s;
33
+ const parts = [...s.matchAll(/<S S="Error">([\s\S]*?)<\/S>/g)].map((m) => m[1]);
34
+ return parts.join('').replace(/_x000D__x000A_/g, '\n').replace(/_x([0-9A-F]{4})_/g, (m, h) => String.fromCharCode(parseInt(h, 16)))
35
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
36
+ }
37
+ /** Any error → one short paragraph for the AI: what happened, what to do, (code). No stack, no internals. */
38
+ export function errorText(e) {
39
+ if (e instanceof ToolError) return `${e.message}${e.hint ? ` What to do: ${e.hint}` : ''} (code ${e.code})`;
40
+ const m = NODE_ERRORS[e?.code];
41
+ if (m) return `${m[0]}${m[1] ? ` What to do: ${m[1]}` : ''} (code ${e.code})`;
42
+ const msg = clean(e?.message || e) || 'Something went wrong.';
43
+ return `${msg} (code INTERNAL)`;
44
+ }
45
+
46
+ /** An engine call that threw (not a tool error result) → plain words; MCP error codes, no stack. */
47
+ export function engineErrorText(e) {
48
+ const code = e?.code, msg = clean(e?.message || e).split('\n')[0].replace(/^MCP error -?\d+:\s*/, '');
49
+ if (code === -32001 || /timed? ?out/i.test(msg)) return 'The PC did not finish this in time — it may still be running. Check the result, or do it in smaller steps. (code ENGINE_TIMEOUT)';
50
+ if (code === -32000 || /connection closed/i.test(msg)) return 'Bridgerator\'s file and terminal service on the PC restarted. Try again in a few seconds. (code ENGINE_RESTARTED)';
51
+ if (code === -32602) return `These arguments don't fit the tool: ${msg.slice(0, 200)} (code BAD_ARGS)`;
52
+ return `The PC could not do this: ${msg.slice(0, 300) || 'unknown error'}. If it repeats, the person can run "bridgerator doctor" on the PC. (code ENGINE_ERROR)`;
53
+ }
54
+ export const text = (t) => ({ content: [{ type: 'text', text: t }] });
55
+ export const json = (o) => ({ content: [{ type: 'text', text: JSON.stringify(o, null, 2) }] });
56
+ export const fail = (code, message, hint) => { throw new ToolError(code, message, hint); };
57
+
58
+ export function needAction(args, actions) {
59
+ if (!actions.includes(args.action)) fail('BAD_ACTION', `action must be one of: ${actions.join(', ')}.`);
60
+ return args.action;
61
+ }
62
+ export function str(args, key, { required = false, max = 2000 } = {}) {
63
+ const v = args[key];
64
+ if (v === undefined || v === null || v === '') { if (required) fail('BAD_ARGS', `${key} is required.`); return undefined; }
65
+ if (typeof v !== 'string') fail('BAD_ARGS', `${key} must be text.`);
66
+ if (v.length > max) fail('BAD_ARGS', `${key} is longer than ${max} characters.`);
67
+ return v;
68
+ }
69
+ export function int(args, key, { min, max, def }) {
70
+ const v = args[key];
71
+ if (v === undefined || v === null) return def;
72
+ if (!Number.isInteger(v) || v < min || v > max) fail('BAD_ARGS', `${key} must be a whole number from ${min} to ${max}.`);
73
+ return v;
74
+ }
75
+
76
+ const inside = (p, d) => p === d || p.startsWith(d + path.sep);
77
+ /**
78
+ * The folder rules for a path an extra tool reads (write=false) or writes (write=true) — the same the
79
+ * engine's file tools get: absolute local path only, inside the allowed list (when there is one),
80
+ * not in an excluded folder, not in a read-only folder for writes. → the absolute path, or throws.
81
+ */
82
+ export function guard(ctx, p, { write = false, tree = false } = {}) {
83
+ if (typeof p !== 'string' || !p.trim()) fail('BAD_PATH', 'A file or folder path is required.');
84
+ const s = p.trim();
85
+ if (/^[\\/]{2}/.test(s)) fail('BAD_PATH', `Network and device paths ("${s}") are not allowed.`);
86
+ if (!/^[a-z]:[\\/]/i.test(s) && !(process.platform !== 'win32' && s.startsWith('/'))) fail('BAD_PATH', `"${s}" is not a full path.`, 'Use a full path like C:\\Users\\Name\\Documents\\file.docx.');
87
+ const abs = path.resolve(s);
88
+ const refusal = ctx.policy?.check(write ? 'write_file' : 'read_file', { path: abs });
89
+ if (refusal) fail('ACCESS_DENIED', refusal.replace(/^Access denied by Bridgerator: /, ''), 'Choose another folder, or ask the person to change Bridgerator → Safety.');
90
+ const allowed = ctx.cfg?.allowedDirectories || [];
91
+ if (allowed.length) {
92
+ const n = norm(realish(abs));
93
+ if (!allowed.some((d) => inside(n, norm(realish(d))))) fail('ACCESS_DENIED', `"${s}" is outside the folders allowed on this PC.`, 'Use a path inside an allowed folder (bridgerator_settings get shows them).');
94
+ }
95
+ // tree: the tool works on the whole folder (copies, walks, recycles below it) → it must not CONTAIN an
96
+ // excluded folder (Bridgerator's own ones included), nor a read-only one for writes.
97
+ if (tree && excludedInside(ctx, abs, { write }).length) fail('FOLDER_HAS_EXCLUDED', `"${s}" contains a folder that is excluded on this PC${write ? ' or read-only' : ''} (or belongs to Bridgerator), so it can't be used as a whole.`, 'Choose a subfolder that holds only the files of this task.');
98
+ return abs;
99
+ }
100
+ /**
101
+ * Excluded (and, for writes, read-only) folders strictly INSIDE folder `abs` → their paths relative to
102
+ * it (lower case, "\"-joined on Windows), [] when none. Same normalisation as policy (realish + norm).
103
+ */
104
+ export function excludedInside(ctx, abs, { write = false } = {}) {
105
+ const nf = norm(realish(abs)), pre = nf.endsWith(path.sep) ? nf : nf + path.sep;
106
+ const list = [...(ctx.policy?.denied || []), ...(write ? (ctx.cfg?.readOnlyDirectories || []).map((d) => norm(realish(d))) : [])];
107
+ return [...new Set(list.filter((d) => d.startsWith(pre)).map((d) => d.slice(pre.length)))];
108
+ }
109
+ /** Paths of search results / listings that the person excluded → dropped (same as policy.filter). */
110
+ export function visiblePath(ctx, p) {
111
+ try { guard(ctx, p); return true; } catch { return false; }
112
+ }
113
+
114
+ /** A new file never overwrites: "name.ext" → "name (2).ext" … when it exists. */
115
+ export function freeName(p) {
116
+ if (!fs.existsSync(p)) return p;
117
+ const { dir, name, ext } = path.parse(p);
118
+ for (let i = 2; i < 1000; i++) { const c = path.join(dir, `${name} (${i})${ext}`); if (!fs.existsSync(c)) return c; }
119
+ fail('EXISTS', `Too many files named like "${name}${ext}".`);
120
+ }
121
+
122
+ // PowerShell: UTF-8 out, errors as text, no profile. Script text inline (-EncodedCommand, small
123
+ // scripts) or read from package files (long scripts). Input data only through env (BRX_*) / stdin,
124
+ // never interpolated into the script.
125
+ const PRELUDE = "$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue';[Console]::OutputEncoding=[Text.Encoding]::UTF8;$OutputEncoding=[Text.Encoding]::UTF8\n";
126
+ // A script may start programs of its own: a timeout or the bridge exiting ends the whole process tree
127
+ // (taskkill /T), not only powershell.exe.
128
+ const live = new Set();
129
+ export function killTree(child, { sync = false } = {}) {
130
+ if (!child?.pid) return;
131
+ if (process.platform === 'win32') {
132
+ const a = ['/PID', String(child.pid), '/T', '/F'];
133
+ try {
134
+ if (sync) spawnSync(TASKKILL, a, { windowsHide: true, stdio: 'ignore', timeout: 5000 });
135
+ else { const k = spawn(TASKKILL, a, { windowsHide: true, stdio: 'ignore' }); k.on('error', () => { try { child.kill(); } catch { /* gone */ } }); return; }
136
+ } catch { /* fall back to kill */ }
137
+ }
138
+ try { child.kill(); } catch { /* gone */ }
139
+ }
140
+ process.once('exit', () => { for (const c of live) killTree(c, { sync: true }); });
141
+ /**
142
+ * → { code, stdout, stderr }. opts: { files: [abs .ps1 paths] } or { script }, env: {}, timeout ms, input.
143
+ * Not Windows → ToolError NOT_WINDOWS.
144
+ */
145
+ export function runPs({ script, files, env = {}, timeout = 60000, input } = {}) {
146
+ if (process.platform !== 'win32') return Promise.reject(new ToolError('NOT_WINDOWS', 'This works only on Windows.'));
147
+ const cmd = files ? PRELUDE + runFilesPs(...files) : PRELUDE + script;
148
+ return new Promise((resolve, reject) => {
149
+ // PSModulePath of a PowerShell 7 parent would make 5.1 load the wrong modules: let 5.1 use its own.
150
+ const base = stripOurs(process.env);
151
+ for (const k of Object.keys(base)) if (k.toLowerCase() === 'psmodulepath') delete base[k];
152
+ const child = spawn(POWERSHELL, psArgs(cmd), { windowsHide: true, env: { ...base, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
153
+ const out = [], err = [];
154
+ let size = 0, done = false;
155
+ live.add(child);
156
+ const timer = setTimeout(() => { if (!done) { killTree(child); finish(new ToolError('TIMEOUT', 'Windows took too long to answer.', 'Try again, or ask for less at once.')); } }, timeout);
157
+ const finish = (e, r) => { if (done) return; done = true; clearTimeout(timer); live.delete(child); e ? reject(e) : resolve(r); };
158
+ child.stdout.on('data', (b) => { size += b.length; if (size < 64 * 1024 * 1024) out.push(b); });
159
+ child.stderr.on('data', (b) => { if (err.length < 200) err.push(b); });
160
+ child.on('error', (e) => finish(new ToolError('PS_START', `PowerShell did not start: ${e.code || e.message}.`, 'Check that Windows PowerShell is not blocked on this PC.')));
161
+ child.on('close', (code) => finish(null, { code, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8') }));
162
+ child.stdin.on('error', () => { /* child exited early: close reports it */ });
163
+ child.stdin.end(input ?? '');
164
+ });
165
+ }
166
+ /** Runs a script that prints ONE JSON object as its last line → that object. ps = ctx.runPs (tests) or runPs. */
167
+ export async function psJson(ctx, opts) {
168
+ const r = await (ctx?.runPs || runPs)(opts);
169
+ const lines = String(r.stdout || '').trim().split(/\r?\n/).filter(Boolean);
170
+ for (let i = lines.length - 1; i >= 0; i--) {
171
+ const l = lines[i].trim();
172
+ if (l.startsWith('{') || l.startsWith('[')) { try { return JSON.parse(l); } catch { /* not this line */ } }
173
+ }
174
+ const why = clean(r.stderr).split(/\r?\n/)[0] || `exit code ${r.code}`;
175
+ throw new ToolError('PS_FAILED', `Windows could not do it: ${why}`, 'Try again; if it repeats, run "bridgerator doctor" on the PC.');
176
+ }
177
+ export const pkgFile = (rel) => new URL(rel, import.meta.url);
@@ -0,0 +1,45 @@
1
+ // LOGIC: see critical-logic/bridge.md ("Tool descriptions"). The engine's own tool descriptions are long
2
+ // and imperative (IMPORTANT / ALWAYS / MUST, caps): they cost tokens on every request and make ChatGPT's
3
+ // safety layer flag the app ("suspicious instruction"). The bridge sends these short, plain ones
4
+ // instead; names, schemas and annotations stay the engine's. A tool missing here keeps its debranded
5
+ // engine text (a new engine version still works).
6
+ const SHARED = ' Other chats may use this PC at the same time: re-read a file right before changing it.';
7
+ export const SHORT = {
8
+ get_config: 'Shows this PC\'s engine settings: allowed folders, blocked commands, default shell, line limits and system info.',
9
+ read_file: 'Reads a local file. Text: offset/length in lines (negative offset = last lines). Excel: sheet and range like "A1:D100". PDF: text by pages. DOCX: an outline by default; offset ≥ 1 gives the raw XML for edit_block. Images come back viewable. Use absolute paths.',
10
+ read_multiple_files: 'Reads several files at once (text and images); a failed file does not stop the others. Use absolute paths.',
11
+ write_file: `Writes a file: mode "rewrite" (default) or "append". Long files are best written in parts of about 30 lines (first rewrite, then append). Excel: a JSON 2D array or {"Sheet": [[...]]}. A new .docx can be written from markdown-style text; edit existing .docx with edit_block. Previous content is kept for Undo.${SHARED}`,
12
+ create_directory: 'Creates a folder, including missing parent folders.',
13
+ list_directory: 'Lists a folder with [FILE] / [DIR] marks; depth sets how many levels (default 2, nested folders show up to 100 items).',
14
+ move_file: `Moves or renames a file or folder. It can be undone (bridgerator_undo).${SHARED}`,
15
+ start_search: 'Starts a background search and returns a session id. searchType "files" matches names, "content" searches inside files; literalSearch for exact text with special characters; filePattern like "*.js". Read results with get_more_search_results.',
16
+ get_more_search_results: 'Returns results of a search started with start_search; offset/length page through them (negative offset = last results).',
17
+ stop_search: 'Stops a running search; its results stay readable for a few minutes.',
18
+ list_searches: 'Lists running searches with their ids and status.',
19
+ get_file_info: 'File or folder details: size, dates, type; line counts for text files, sheets for Excel files.',
20
+ edit_block: `Edits a file in place. Text and DOCX: replaces old_string with new_string (one match unless expected_replacements says more; include a few lines of context). Excel: range like "Sheet1!A1:C10" with content as a 2D array. Previous content is kept for Undo.${SHARED}`,
21
+ start_process: 'Runs a command or program in a terminal session on this PC (PowerShell by default; shell can be "cmd"). Returns the output, or the session pid when it keeps running. Follow up with read_process_output or interact_with_process. Simple delete commands move files to the Recycle Bin.',
22
+ read_process_output: 'Reads new output from a running session (offset/length in lines; negative offset = last lines) and shows whether it waits for input or has finished.',
23
+ interact_with_process: 'Sends input to a running session or REPL (pid from start_process) and returns the new output.',
24
+ force_terminate: 'Stops a terminal session started with start_process.',
25
+ list_sessions: 'Lists terminal sessions: pid, whether one waits for input, how long it runs.',
26
+ list_processes: 'Lists running programs with pid, CPU and memory.',
27
+ kill_process: 'Ends a running program by pid.',
28
+ get_prompts: 'Returns a ready-made task prompt by id (action "get_prompt").',
29
+ };
30
+
31
+ // Our own tools: hints for the client (the relay passes them as they are).
32
+ const RO = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
33
+ const RW = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
34
+ const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
35
+ export const OWN_ANNOTATIONS = {
36
+ queue_list: RO, queue_next: RW, queue_update: RW, queue_add: RW, queue_edit: RW, queue_reorder: RW, queue_clear: DESTRUCTIVE,
37
+ bridgerator_setup: RW, bridgerator_settings: RW, request_confirm_off: RW, confirm_on: RW, bridgerator_undo: DESTRUCTIVE,
38
+ };
39
+
40
+ /** Tool (debranded) → the short description; annotations kept, ours added. */
41
+ export function shorten(t) {
42
+ const d = SHORT[t.name];
43
+ const ann = t.annotations || OWN_ANNOTATIONS[t.name];
44
+ return { ...t, ...(d ? { description: d } : {}), ...(ann ? { annotations: ann } : {}) };
45
+ }
package/src/tray.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // The tray helper (tray.ps1, one hidden PowerShell per bridge) and the JSON-lines link to it:
2
+ // icon + menu (Pause / Resume / Quit), the questions on the PC (toast / fallback window, already
3
+ // loaded → instant) and the requests of its window (rpc → handled in the bridge, rpc.mjs).
4
+ import os from 'node:os';
5
+ import readline from 'node:readline';
6
+ import { spawn as nodeSpawn } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { POWERSHELL } from './win.mjs';
9
+ import { runFilesPs, askOnPc, stripOurs } from './ask.mjs';
10
+ import { localizeAsk, attrLine } from './pc-text.mjs';
11
+
12
+ const LIB = fileURLToPath(new URL('./ask-lib.ps1', import.meta.url));
13
+ const TRAY = fileURLToPath(new URL('./tray.ps1', import.meta.url));
14
+ export const PKG_DIR = fileURLToPath(new URL('..', import.meta.url)).replace(/[\\/]+$/, '');
15
+
16
+ /**
17
+ * The protocol, without the process: `write(line)` sends to the helper; feed helper lines to
18
+ * `onLine`. onMenu(action), onRpc(method, params, paths) → Promise<result>.
19
+ */
20
+ export function createLink(write, { onMenu = () => {}, onRpc = async () => null, onLog = () => {}, onPower = () => {}, now = Date.now, setTimer = setTimeout } = {}) {
21
+ let ready = false, seq = 0;
22
+ const waiting = new Map(); // id → { resolve, keys, timer }
23
+ const send = (o) => write(`${JSON.stringify(o)}\n`);
24
+ const finish = (id, key) => { const w = waiting.get(id); if (!w) return; waiting.delete(id); clearTimeout(w.timer); w.resolve(w.keys.includes(key) ? key : 'deny'); };
25
+ return {
26
+ get ready() { return ready; },
27
+ onLine(line) {
28
+ let m; try { m = JSON.parse(line); } catch { return; }
29
+ if (m.t === 'ready') ready = true;
30
+ else if (m.t === 'log' && typeof m.msg === 'string') onLog(m.msg.slice(0, 2000));
31
+ // Keep-awake as the helper applied it: on = holding the PC awake, ac = on mains power.
32
+ else if (m.t === 'power') onPower({ on: m.on === true, ac: m.ac !== false });
33
+ else if (m.t === 'answer' && typeof m.id === 'string') finish(m.id, String(m.key));
34
+ else if (m.t === 'menu' && ['pause', 'resume', 'quit', 'confirm'].includes(m.action)) onMenu(m.action, { minutes: Number.isInteger(m.minutes) ? m.minutes : undefined, on: m.on === true });
35
+ else if (m.t === 'rpc' && typeof m.id === 'string' && typeof m.method === 'string') {
36
+ Promise.resolve().then(() => onRpc(m.method, m.params ?? {}, Array.isArray(m.paths) ? m.paths.filter((p) => typeof p === 'string') : []))
37
+ .then((result) => send({ t: 'rpc-result', id: m.id, result: result ?? null }), (e) => send({ t: 'rpc-result', id: m.id, error: String(e?.message || e) }));
38
+ }
39
+ },
40
+ state: (o) => send({ t: 'state', ...o }),
41
+ open: (page) => send({ t: 'open', page }),
42
+ /** Same contract as askOnPc → the chosen key, 'deny' on timeout / no helper. */
43
+ ask(q0) {
44
+ // The PC's language (V-3): fixed texts, buttons and the "auto-deny in 2 min" line.
45
+ const { head, text, buttons, secs = 120, host = os.hostname(), auto = 'auto-deny', theme = 'system', silent = false, style = 'toast', lang = 'en' } = localizeAsk({ ...q0, auto: q0.auto || 'auto-deny' }, q0.lang);
46
+ const id = `a${++seq}-${now().toString(36)}`;
47
+ const attr = attrLine(host, auto, secs, lang);
48
+ return new Promise((resolve) => {
49
+ const timer = setTimer(() => finish(id, 'deny'), (secs + 15) * 1000);
50
+ waiting.set(id, { resolve, keys: buttons.map((b) => b[0]), timer });
51
+ send({ t: 'ask', id, head, detail: text, attr, host, auto, secs, theme, silent, style, buttons, lang: lang === 'ru' ? 'ru' : 'en' });
52
+ });
53
+ },
54
+ lost() { ready = false; for (const id of [...waiting.keys()]) finish(id, 'deny'); },
55
+ };
56
+ }
57
+
58
+ /** Starts the helper (Windows only) and keeps it running while the bridge runs. → link-like object. */
59
+ export function startTray({ log = console.log, onMenu, onRpc, onPower, spawn = nodeSpawn, stderr = 'ignore', testEnv = {}, account = '' } = {}) {
60
+ if (process.platform !== 'win32') return null;
61
+ let child = null, link = null, closing = false, restartTimer = null;
62
+ // Our own variables only: BR_* / BRIDGERATOR_* from the environment are ignored (a test harness
63
+ // passes its hooks explicitly as testEnv).
64
+ // BR_ACCOUNT: the account page of the site this PC is linked to (a staging install opens staging; V-5).
65
+ const env = { ...stripOurs(process.env), ...testEnv, BR_PARENT: String(process.pid), BR_PKG: PKG_DIR, ...(/^https:\/\/[a-z0-9.-]+(:\d+)?\/app$/i.test(account) ? { BR_ACCOUNT: account } : {}) };
66
+ const start = () => {
67
+ // Plain -Command (not encoded): its command line names the package folder, so the one process
68
+ // matcher (stop / update / uninstall) finds the helper too.
69
+ child = spawn(POWERSHELL, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', runFilesPs(LIB, TRAY)], { env, stdio: ['pipe', 'pipe', stderr], windowsHide: true });
70
+ const c = child;
71
+ // An EPIPE after the helper died is an 'error' event on the stream: without a listener it would crash the bridge.
72
+ c.stdin?.on('error', () => {});
73
+ link = createLink((l) => { try { c.stdin.write(l); } catch { /* gone */ } }, { onMenu, onRpc, onPower, onLog: (msg) => log(`[bridgerator] ${msg}`) });
74
+ readline.createInterface({ input: child.stdout }).on('line', (l) => link.onLine(l));
75
+ child.on('error', (e) => log(`[bridgerator] tray: ${e.message}`));
76
+ child.on('exit', (code) => {
77
+ link.lost();
78
+ if (!closing) { log(`[bridgerator] tray helper stopped (${code}); restarting in 30 s`); restartTimer = setTimeout(() => { restartTimer = null; if (!closing) start(); }, 30000); restartTimer.unref(); }
79
+ });
80
+ };
81
+ start();
82
+ // Questions go through the helper when it is up, otherwise one PowerShell per question.
83
+ return {
84
+ get ready() { return !!link?.ready; },
85
+ get pid() { return child?.pid; },
86
+ ask: (q) => (link?.ready ? link.ask(q) : askOnPc(q)),
87
+ state: (o) => link?.state(o),
88
+ open: (page) => link?.open(page),
89
+ // 'exit' + closing stdin let the helper hide + dispose its tray icon itself (a kill leaves a ghost icon); the kill is the fallback.
90
+ close: () => {
91
+ closing = true; clearTimeout(restartTimer);
92
+ try { child?.stdin?.write('{"t":"exit"}\n'); } catch { /* gone */ }
93
+ try { child?.stdin?.end(); } catch { /* gone */ }
94
+ const c = child; const t = setTimeout(() => { try { c?.kill(); } catch { /* gone */ } }, 2000); t.unref();
95
+ },
96
+ };
97
+ }