bridgerator 1.0.7 → 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.
- package/README.md +78 -15
- package/assets/bridgerator.ico +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-20.png +0 -0
- package/assets/icon-24.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/logo.svg +1 -0
- package/install.ps1 +465 -43
- package/npm-shrinkwrap.json +8621 -0
- package/package.json +55 -49
- package/recipes/backup-folder.json +31 -0
- package/recipes/clean-downloads.json +48 -0
- package/recipes/disk-report.json +66 -0
- package/recipes/sort-folder.json +49 -0
- package/recipes/update-apps.json +25 -0
- package/src/ask-lib.ps1 +230 -0
- package/src/ask.mjs +51 -0
- package/src/ask.ps1 +17 -0
- package/src/automations.mjs +673 -0
- package/src/batch.mjs +221 -0
- package/src/bridge.mjs +743 -29
- package/src/cli.mjs +561 -53
- package/src/confirm.mjs +418 -0
- package/src/dc-config-hooks.mjs +18 -8
- package/src/dc-isolate.mjs +5 -0
- package/src/delegate.mjs +555 -0
- package/src/docs-tool.mjs +494 -0
- package/src/doctor.mjs +200 -0
- package/src/events.mjs +21 -0
- package/src/extra-tools.mjs +122 -0
- package/src/guard.mjs +34 -0
- package/src/notice.mjs +86 -23
- package/src/pair.mjs +41 -6
- package/src/pc-check.mjs +112 -0
- package/src/pc-check.ps1 +103 -0
- package/src/pc-text.mjs +94 -0
- package/src/pdf-font.mjs +245 -0
- package/src/photos-tool.mjs +187 -0
- package/src/photos.ps1 +145 -0
- package/src/plan.mjs +90 -0
- package/src/policy.mjs +293 -109
- package/src/queue.mjs +194 -0
- package/src/recent.mjs +48 -0
- package/src/recipes.mjs +108 -0
- package/src/recycle.mjs +185 -0
- package/src/recycle.ps1 +110 -0
- package/src/screen-tool.mjs +185 -0
- package/src/screen.ps1 +285 -0
- package/src/search-tool.mjs +189 -0
- package/src/search.ps1 +129 -0
- package/src/settings-tool.mjs +186 -0
- package/src/setup-tool.mjs +129 -0
- package/src/software-tool.mjs +185 -0
- package/src/telemetry.mjs +86 -0
- package/src/tool-lib.mjs +177 -0
- package/src/tool-text.mjs +45 -0
- package/src/tray.mjs +97 -0
- package/src/tray.ps1 +359 -0
- package/src/undo.mjs +313 -0
- package/src/win.mjs +17 -0
- package/ui/app.css +388 -0
- package/ui/app.js +868 -0
- package/ui/i18n/en.json +598 -0
- package/ui/i18n/ru.json +598 -0
- package/ui/index.html +26 -0
- package/vendor/webview2/LICENSE.txt +27 -0
- package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
- package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/vendor/webview2/NOTICE.txt +85 -0
- package/vendor/webview2/SHA256SUMS.txt +4 -0
- package/vendor/webview2/SOURCE.txt +1 -0
- package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
- package/vendor/webview2/x64/WebView2Loader.dll +0 -0
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Doctor"). `bridgerator doctor` (CLI) and the bridgerator_doctor
|
|
2
|
+
// tool: checks what the link to ChatGPT needs, each with a plain-words result and what to do.
|
|
3
|
+
// Every check has three outcomes plus a warning: ok / warn / fail / not_checked ("could not check" is
|
|
4
|
+
// never a silent ok). Windows facts come from one PowerShell run, read by registry values and enum
|
|
5
|
+
// numbers (any locale). Nothing here changes the PC; the WebSocket probe sends no key (a second link
|
|
6
|
+
// with this PC's key would take over the running one).
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import WebSocket from 'ws';
|
|
11
|
+
import { checkKey, siteOf } from './pair.mjs';
|
|
12
|
+
import { psJson, json } from './tool-lib.mjs';
|
|
13
|
+
|
|
14
|
+
const MB = 1024 * 1024;
|
|
15
|
+
// Scheduled task state numbers (Microsoft.Management.Infrastructure CimInstance StateEnum).
|
|
16
|
+
const TASK_STATE = { 0: 'unknown', 1: 'disabled', 2: 'queued', 3: 'ready', 4: 'running' };
|
|
17
|
+
|
|
18
|
+
export const WIN_PS = String.raw`
|
|
19
|
+
$o = [ordered]@{}
|
|
20
|
+
$o.psVersion = $PSVersionTable.PSVersion.ToString()
|
|
21
|
+
$o.languageMode = [string]$ExecutionContext.SessionState.LanguageMode
|
|
22
|
+
$o.policies = @{}
|
|
23
|
+
foreach ($p in (Get-ExecutionPolicy -List)) { $o.policies[[string]$p.Scope] = [string]$p.ExecutionPolicy }
|
|
24
|
+
$o.webview2 = $null
|
|
25
|
+
foreach ($k in 'HKCU:\Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}') {
|
|
26
|
+
try { $pv = (Get-ItemProperty $k -ErrorAction Stop).pv; if ($pv -and $pv -ne '0.0.0.0') { $o.webview2 = [string]$pv; break } } catch {}
|
|
27
|
+
}
|
|
28
|
+
$task = $env:BRX_TASK
|
|
29
|
+
if (-not $task) { $task = 'Bridgerator-' + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value }
|
|
30
|
+
try { $t = Get-ScheduledTask -TaskName $task -ErrorAction Stop; $o.task = @{ state = [int]$t.State } } catch {
|
|
31
|
+
# Up to 1.0.7 one task name for every user of the PC.
|
|
32
|
+
try { $t = Get-ScheduledTask -TaskName 'Bridgerator' -ErrorAction Stop; $o.task = @{ state = [int]$t.State; old = $true } } catch { $o.task = @{ missing = $true } }
|
|
33
|
+
}
|
|
34
|
+
$o.proxy = @{}
|
|
35
|
+
try {
|
|
36
|
+
$i = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction Stop
|
|
37
|
+
$o.proxy = @{ enabled = ([int]$i.ProxyEnable -eq 1); server = [string]$i.ProxyServer; pac = [string]$i.AutoConfigURL }
|
|
38
|
+
} catch {}
|
|
39
|
+
$o | ConvertTo-Json -Compress -Depth 4
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const R = (id, status, title, detail = '', fix = '') => ({ id, status, title, ...(detail ? { detail } : {}), ...(fix ? { fix } : {}) });
|
|
43
|
+
|
|
44
|
+
/** Network error → plain words + what to do (the cause, not the stack). */
|
|
45
|
+
export function netWhy(e) {
|
|
46
|
+
const code = e?.cause?.code || e?.code || '';
|
|
47
|
+
const name = e?.name || '';
|
|
48
|
+
if (name === 'TimeoutError' || name === 'AbortError' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ETIMEDOUT') return ['no answer in time', 'Check the internet connection; a firewall or VPN may block the connection.'];
|
|
49
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return ['the server name could not be found (DNS)', 'Check the internet connection or DNS settings.'];
|
|
50
|
+
if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'UND_ERR_SOCKET') return ['the connection was refused or cut', 'A firewall, antivirus or proxy may block it; try another network.'];
|
|
51
|
+
if (/CERT|SELF_SIGNED|UNABLE_TO_(GET|VERIFY)/.test(code)) return ['the secure connection was replaced by someone in between (certificate not trusted)', 'An antivirus "HTTPS scanning" or a company proxy inspects traffic: add bridgerator.recondeck.com to its exclusions.'];
|
|
52
|
+
return [String(code || e?.message || e).slice(0, 120), 'Check the internet connection and try again.'];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const wsProbe = (url, timeout = 10000) => new Promise((resolve) => {
|
|
56
|
+
let done = false;
|
|
57
|
+
const ws = new WebSocket(url, { handshakeTimeout: timeout });
|
|
58
|
+
const end = (r) => { if (done) return; done = true; try { ws.terminate(); } catch { /* closed */ } resolve(r); };
|
|
59
|
+
ws.on('open', () => end({ ok: true }));
|
|
60
|
+
ws.on('unexpected-response', (_q, res) => end({ ok: false, status: res.statusCode }));
|
|
61
|
+
ws.on('error', (e) => end({ ok: false, error: e }));
|
|
62
|
+
setTimeout(() => end({ ok: false, error: Object.assign(new Error('timeout'), { name: 'TimeoutError' }) }), timeout + 1000).unref?.();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const newer = (a, b) => { const x = String(a).split('.').map(Number), y = String(b).split('.').map(Number); for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0); return false; };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* → { ok: boolean (no fail), results: [{ id, status, title, detail?, fix? }] }.
|
|
69
|
+
* deps (tests): fetch, wsOpen(url), runPs, now(), statfs(dir), platform, nodeVersion, taskName, running (CLI), linkState (inside the bridge).
|
|
70
|
+
*/
|
|
71
|
+
export async function runDoctor(cfg, { version, fetch: f = globalThis.fetch, wsOpen = wsProbe, runPs, now = Date.now, statfs = (d) => fs.statfsSync(d), platform = process.platform, nodeVersion = process.versions.node, taskName, running, linkState, configDir } = {}) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const major = Number(String(nodeVersion).split('.')[0]);
|
|
74
|
+
out.push(major >= 22 ? R('node', 'ok', `Node.js ${nodeVersion}`) : R('node', 'fail', `Node.js ${nodeVersion} is too old`, 'Bridgerator needs Node.js 22 or newer.', 'Run the install command from https://bridgerator.recondeck.com/app again: it brings its own Node.js.'));
|
|
75
|
+
|
|
76
|
+
// Bridge version vs the newest one on npm.
|
|
77
|
+
try {
|
|
78
|
+
const r = await f('https://registry.npmjs.org/bridgerator/latest', { signal: AbortSignal.timeout(10000) });
|
|
79
|
+
const latest = String((await r.json()).version || '');
|
|
80
|
+
out.push(!latest ? R('version', 'not_checked', `Bridgerator ${version}`, 'the update server gave no version')
|
|
81
|
+
: newer(latest, version) ? R('version', 'warn', `Bridgerator ${version} — ${latest} is available`, '', 'Run the install command again to update (settings are kept).')
|
|
82
|
+
: R('version', 'ok', `Bridgerator ${version} (latest)`));
|
|
83
|
+
} catch (e) { out.push(R('version', 'not_checked', `Bridgerator ${version}`, `update check failed: ${netWhy(e)[0]}`)); }
|
|
84
|
+
|
|
85
|
+
if (!cfg) {
|
|
86
|
+
out.push(R('config', 'fail', 'Bridgerator is not set up on this PC', '', 'Run the install command from https://bridgerator.recondeck.com/app.'));
|
|
87
|
+
return finish(out);
|
|
88
|
+
}
|
|
89
|
+
if (!cfg.token) out.push(R('key', 'fail', 'This PC has no Bridgerator key', 'It was unlinked or never paired.', 'Run the install command again and confirm the PC in the browser.'));
|
|
90
|
+
|
|
91
|
+
let relay = null, site = null;
|
|
92
|
+
try { relay = new URL(cfg.relay); site = siteOf(cfg.relay); } catch { out.push(R('relay', 'fail', 'The server address in the settings is broken', String(cfg.relay).slice(0, 80), 'Run the install command again.')); }
|
|
93
|
+
|
|
94
|
+
if (relay) {
|
|
95
|
+
// Relay reachable over HTTPS + the clock (TLS and sign-in break when the clock is far off).
|
|
96
|
+
const health = `https://${relay.host}/healthz`;
|
|
97
|
+
try {
|
|
98
|
+
const t0 = now();
|
|
99
|
+
const r = await f(health, { signal: AbortSignal.timeout(10000) });
|
|
100
|
+
const ms = now() - t0;
|
|
101
|
+
out.push(r.ok ? R('relay', 'ok', `Server reachable (${ms} ms)`) : R('relay', 'fail', `Server answered HTTP ${r.status}`, '', 'Try again in a few minutes; if it stays, write to support at https://bridgerator.recondeck.com/support.'));
|
|
102
|
+
const date = Date.parse(r.headers?.get?.('date') || '');
|
|
103
|
+
if (Number.isFinite(date)) {
|
|
104
|
+
const skew = Math.round((now() - date) / 1000);
|
|
105
|
+
out.push(Math.abs(skew) > 300 ? R('clock', 'fail', `The PC clock is off by ${Math.round(Math.abs(skew) / 60)} min`, 'Secure connections and sign-in can fail.', 'Windows Settings → Time & language → Date & time → "Set time automatically" → Sync now.')
|
|
106
|
+
: R('clock', 'ok', `Clock in sync (${skew >= 0 ? '+' : ''}${skew} s)`));
|
|
107
|
+
} else out.push(R('clock', 'not_checked', 'Clock', 'the server sent no time'));
|
|
108
|
+
} catch (e) {
|
|
109
|
+
const [why, fix] = netWhy(e);
|
|
110
|
+
out.push(R('relay', 'fail', 'Server not reachable', why, fix));
|
|
111
|
+
out.push(R('clock', 'not_checked', 'Clock', 'server not reachable'));
|
|
112
|
+
}
|
|
113
|
+
// WebSocket upgrade (proxies that pass HTTPS but drop WebSockets), without the key.
|
|
114
|
+
const w = await wsOpen(cfg.relay).catch((e) => ({ ok: false, error: e }));
|
|
115
|
+
if (w.ok || [401, 403, 400].includes(w.status)) out.push(R('websocket', 'ok', 'Live connection (WebSocket) works'));
|
|
116
|
+
else if (w.status) out.push(R('websocket', 'fail', `Live connection refused (HTTP ${w.status})`, 'Something between this PC and the server blocks WebSockets.', 'A company proxy or antivirus web filter may block it: allow mcp.bridgerator.recondeck.com.'));
|
|
117
|
+
else { const [why, fix] = netWhy(w.error); out.push(R('websocket', 'fail', 'Live connection (WebSocket) failed', why, fix)); }
|
|
118
|
+
// Key still accepted?
|
|
119
|
+
if (cfg.token && site) {
|
|
120
|
+
const k = await checkKey(site, cfg.token, { fetch: f });
|
|
121
|
+
out.push(k === 'ok' ? R('key', 'ok', 'This PC\'s key is accepted')
|
|
122
|
+
: k === 'rejected' ? R('key', 'fail', 'This PC was removed from your account', 'The server no longer accepts its key.', 'Run the install command again and confirm the PC in the browser.')
|
|
123
|
+
: R('key', 'not_checked', 'This PC\'s key', 'the server could not be asked'));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (linkState) out.push(linkState === 'online' ? R('link', 'ok', 'Connected to the server now') : R('link', 'warn', `Link state: ${linkState}`, '', 'It reconnects by itself; if it stays offline, see the checks above.'));
|
|
127
|
+
if (running !== undefined) out.push(running ? R('running', 'ok', 'Bridgerator is running') : R('running', 'fail', 'Bridgerator is not running', '', 'Sign out and in again, or run the install command again.'));
|
|
128
|
+
|
|
129
|
+
// Free disk space where Bridgerator keeps its files and on the system drive.
|
|
130
|
+
const rootOf = (d) => (platform === 'win32' ? path.win32.parse(path.win32.resolve(d)).root.toUpperCase() : d);
|
|
131
|
+
const dirs = [...new Map([configDir, platform === 'win32' ? `${process.env.SystemDrive || 'C:'}\\` : '/'].filter(Boolean).map((d) => [rootOf(d), d])).values()];
|
|
132
|
+
for (const d of dirs) {
|
|
133
|
+
try {
|
|
134
|
+
const s = statfs(d);
|
|
135
|
+
const free = Number(s.bavail) * Number(s.bsize);
|
|
136
|
+
const label = platform === 'win32' ? rootOf(d).replace(/\\$/, '') : d;
|
|
137
|
+
const gb = (free / 1024 / MB).toFixed(1);
|
|
138
|
+
out.push(free < 200 * MB ? R(`disk ${label}`, 'fail', `Disk ${label} only ${Math.round(free / MB)} MB free`, '', 'Free some space (Settings → System → Storage).')
|
|
139
|
+
: free < 1024 * MB ? R(`disk ${label}`, 'warn', `Disk ${label} ${gb} GB free`, 'Snapshots for Undo and updates need space.', 'Free some space soon.')
|
|
140
|
+
: R(`disk ${label}`, 'ok', `Disk ${label} ${gb} GB free`));
|
|
141
|
+
} catch (e) { out.push(R(`disk ${d}`, 'not_checked', `Disk ${d}`, String(e?.code || e?.message || e))); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (platform === 'win32') {
|
|
145
|
+
let w = null;
|
|
146
|
+
try { w = await psJson({ runPs }, { script: WIN_PS, env: { BRX_TASK: taskName || '' }, timeout: 30000 }); } catch (e) { out.push(R('powershell', 'fail', 'Windows PowerShell does not run', String(e?.message || e).slice(0, 160), 'Bridgerator needs Windows PowerShell 5.1 (part of Windows). Ask the PC administrator whether it is blocked.')); }
|
|
147
|
+
if (w) {
|
|
148
|
+
out.push(w.languageMode === 'FullLanguage' ? R('powershell', 'ok', `PowerShell ${w.psVersion}`)
|
|
149
|
+
: R('powershell', 'warn', `PowerShell runs in ${w.languageMode} mode`, 'A company policy (AppLocker / WDAC) limits PowerShell: notifications, the tray window and some tools may not work.', 'Ask the PC administrator to allow Bridgerator.'));
|
|
150
|
+
const gpo = ['MachinePolicy', 'UserPolicy'].map((k) => w.policies?.[k]).find((v) => v && v !== 'Undefined');
|
|
151
|
+
out.push(gpo ? R('policy', 'warn', `Script policy set by the administrator: ${gpo}`, 'Bridgerator runs its scripts in a way this policy allows in most cases.', 'If notifications or the window do not appear, ask the administrator.')
|
|
152
|
+
: R('policy', 'ok', `Script policy: ${w.policies?.CurrentUser && w.policies.CurrentUser !== 'Undefined' ? w.policies.CurrentUser : w.policies?.LocalMachine || 'default'} (fine — Bridgerator does not need it changed)`));
|
|
153
|
+
out.push(w.webview2 ? R('webview2', 'ok', `Window component (WebView2) ${w.webview2}`) : R('webview2', 'warn', 'Window component (Microsoft WebView2) not installed', 'Bridgerator works; its window opens your account page instead.', 'Install "Microsoft Edge WebView2 Runtime" from microsoft.com, or run the install command again.'));
|
|
154
|
+
{
|
|
155
|
+
const st = w.task?.missing ? 'missing' : TASK_STATE[w.task?.state] || 'unknown';
|
|
156
|
+
out.push((st === 'ready' || st === 'running') && w.task?.old ? R('autostart', 'warn', 'Starts with Windows (old-style task)', '', 'Run the install command again to update it.')
|
|
157
|
+
: st === 'ready' || st === 'running' ? R('autostart', 'ok', 'Starts with Windows')
|
|
158
|
+
: st === 'disabled' ? R('autostart', 'warn', 'Start with Windows is off', '', 'Turn it on in the Bridgerator window → Settings, or run the install command again.')
|
|
159
|
+
: st === 'missing' ? R('autostart', 'fail', 'Start with Windows is not set up', '', 'Run the install command again.')
|
|
160
|
+
: R('autostart', 'not_checked', 'Start with Windows', 'task state unknown'));
|
|
161
|
+
}
|
|
162
|
+
const p = w.proxy || {};
|
|
163
|
+
const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
|
|
164
|
+
if (p.enabled || p.pac) {
|
|
165
|
+
const relayFail = out.some((x) => (x.id === 'relay' || x.id === 'websocket') && x.status === 'fail');
|
|
166
|
+
out.push(R('proxy', relayFail ? 'fail' : 'warn', `A proxy is set in Windows${p.server ? ` (${String(p.server).slice(0, 60)})` : p.pac ? ' (automatic script)' : ''}`, 'Bridgerator connects directly, not through the Windows proxy.', relayFail ? 'Allow direct connections to mcp.bridgerator.recondeck.com (port 443) in the firewall / proxy.' : 'Nothing to do while the connection works.'));
|
|
167
|
+
} else out.push(R('proxy', 'ok', envProxy ? 'Proxy from the environment is set (not used by Bridgerator)' : 'No proxy'));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return finish(out);
|
|
171
|
+
}
|
|
172
|
+
const finish = (results) => ({ ok: !results.some((r) => r.status === 'fail'), results });
|
|
173
|
+
|
|
174
|
+
const MARK = { ok: '[ok] ', warn: '[!] ', fail: '[x] ', not_checked: '[?] ' };
|
|
175
|
+
/** Report for the console. */
|
|
176
|
+
export function formatDoctor({ ok, results }) {
|
|
177
|
+
const lines = ['Bridgerator doctor', ''];
|
|
178
|
+
for (const r of results) {
|
|
179
|
+
lines.push(`${MARK[r.status]}${r.title}${r.detail ? ` — ${r.detail}` : ''}`);
|
|
180
|
+
if (r.fix && r.status !== 'ok') lines.push(` What to do: ${r.fix}`);
|
|
181
|
+
}
|
|
182
|
+
const bad = results.filter((r) => r.status === 'fail').length, warn = results.filter((r) => r.status === 'warn').length, nc = results.filter((r) => r.status === 'not_checked').length;
|
|
183
|
+
lines.push('', ok ? `All needed checks passed${warn ? ` (${warn} warning${warn > 1 ? 's' : ''})` : ''}${nc ? `, ${nc} not checked` : ''}.` : `${bad} problem${bad > 1 ? 's' : ''} found — see "What to do" above.`);
|
|
184
|
+
return lines.join(os.EOL);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- the tool --------------------------------------------------------------------------------
|
|
188
|
+
export const defs = [{
|
|
189
|
+
name: 'bridgerator_doctor',
|
|
190
|
+
title: 'Check the Bridgerator connection',
|
|
191
|
+
description: 'Checks what the link between ChatGPT and this PC needs: Node.js, Bridgerator version, this PC\'s key, server and live connection, clock, proxy, PowerShell, the window component, start with Windows, free disk space. Each result has a plain explanation and what to do. Changes nothing.',
|
|
192
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['action'], properties: { action: { type: 'string', enum: ['check'], description: '"check" runs all checks' } } },
|
|
193
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
194
|
+
}];
|
|
195
|
+
|
|
196
|
+
export async function handle(name, args, ctx) {
|
|
197
|
+
const st = await ctx.rpc?.('getState').catch(() => null);
|
|
198
|
+
const r = await runDoctor(ctx.cfg, { version: ctx.version, runPs: ctx.runPs, linkState: st?.state, configDir: ctx.cfg?.configDir, fetch: ctx.fetch, wsOpen: ctx.wsOpen });
|
|
199
|
+
return json(r);
|
|
200
|
+
}
|
package/src/events.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Relay notifications"). Events the bridge tells the relay so it
|
|
2
|
+
// can notify the person outside the chat (Telegram…): {type:'event', kind, …} with whole numbers only —
|
|
3
|
+
// never titles, names, paths, commands or output. At most 30 a minute (the rest are dropped, logged once).
|
|
4
|
+
// delegate_done {ok: 0|1}: a delegated agent run (delegate.mjs) that went on in the background has ended.
|
|
5
|
+
export const EVENT_KINDS = ['queue_done', 'task_failed', 'automation_failed', 'delegate_done'];
|
|
6
|
+
const FIELDS = ['n', 'code', 'failing', 'done', 'failed', 'ok'];
|
|
7
|
+
export const EVENTS_PER_MIN = 30;
|
|
8
|
+
|
|
9
|
+
/** → the message to send, or null (unknown kind). Non-integer fields are dropped. */
|
|
10
|
+
export function eventMessage(e) {
|
|
11
|
+
if (!e || !EVENT_KINDS.includes(e.kind)) return null;
|
|
12
|
+
const m = { type: 'event', kind: e.kind };
|
|
13
|
+
for (const k of FIELDS) if (Number.isSafeInteger(e[k])) m[k] = e[k];
|
|
14
|
+
return m;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** take() → true while under `max` events in the last `windowMs`. */
|
|
18
|
+
export function createEventLimiter(max = EVENTS_PER_MIN, windowMs = 60000, now = Date.now) {
|
|
19
|
+
let times = [];
|
|
20
|
+
return { take() { const t = now(); times = times.filter((x) => t - x < windowMs); if (times.length >= max) return false; times.push(t); return true; } };
|
|
21
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Extra tools"). The ONE list of the bridge's own extra tools.
|
|
2
|
+
// bridge.mjs only asks this registry: defs (hello), has(name), call(name, args, ctx), settings.
|
|
3
|
+
//
|
|
4
|
+
// A module is { defs, handle, settings? }:
|
|
5
|
+
// defs: MCP tool definitions [{ name, title?, description, inputSchema, annotations }]
|
|
6
|
+
// (few tools with an `action` parameter; short, non-imperative descriptions; honest hints).
|
|
7
|
+
// handle: async (name, args, ctx) → MCP result { content: [...], isError? }.
|
|
8
|
+
// Throw a ToolError (tool-lib.mjs) for a plain-words refusal; any other throw becomes a
|
|
9
|
+
// short message without a stack (registry `call`).
|
|
10
|
+
// settings: optional [{ key: 'group.name', default: boolean, widensWhen: true|false, label: { en, ru }, hint?: { en, ru }, page? }]
|
|
11
|
+
// — stored in config.json as { group: { name: value } }; shown in the window and readable /
|
|
12
|
+
// settable through bridgerator_settings (`widensWhen` = the value that lowers protection or
|
|
13
|
+
// widens access → the person's OK on the PC). Booleans by default; also
|
|
14
|
+
// { type: 'int', min, max } and { type: 'enum', values: [narrow … wide] } with
|
|
15
|
+
// widensWhen 'up' | 'down' | null (a bigger / smaller value widens; null = never asks).
|
|
16
|
+
// page: the window page that shows it (then not in Settings → Tools).
|
|
17
|
+
//
|
|
18
|
+
// ctx (built by bridge.mjs per call): { cfg, policy, gate, log, popup, engine(name, args), rpc(method, p), version }
|
|
19
|
+
// ctx.policy is the CURRENT policy (folders can change at runtime); path checks: tool-lib.mjs guard().
|
|
20
|
+
// Format is fixed: other modules are added by appending to EXTRA_MODULES, nothing else changes.
|
|
21
|
+
import { errorText } from './tool-lib.mjs';
|
|
22
|
+
import * as doctor from './doctor.mjs';
|
|
23
|
+
import * as docs from './docs-tool.mjs';
|
|
24
|
+
import * as search from './search-tool.mjs';
|
|
25
|
+
import * as photos from './photos-tool.mjs';
|
|
26
|
+
import * as software from './software-tool.mjs';
|
|
27
|
+
import * as pcinfo from './pc-check.mjs';
|
|
28
|
+
import * as screen from './screen-tool.mjs';
|
|
29
|
+
import * as automations from './automations.mjs';
|
|
30
|
+
import * as plans from './batch.mjs';
|
|
31
|
+
import * as delegate from './delegate.mjs';
|
|
32
|
+
|
|
33
|
+
export const EXTRA_MODULES = [doctor, docs, search, photos, software, pcinfo, screen, automations, plans, delegate];
|
|
34
|
+
|
|
35
|
+
/** The registry of EXTRA_MODULES (settings-tool.mjs reads the settings part of it). */
|
|
36
|
+
export const extraRegistry = () => (shared ||= createExtra());
|
|
37
|
+
let shared = null;
|
|
38
|
+
|
|
39
|
+
// Typed settings (int / enum) next to the booleans: validation, order for the widen check, schema.
|
|
40
|
+
const kind = (s) => s.type || 'boolean';
|
|
41
|
+
const valid = (s, v) => (kind(s) === 'int' ? Number.isInteger(v) && v >= s.min && v <= s.max : kind(s) === 'enum' ? s.values.includes(v) : typeof v === 'boolean');
|
|
42
|
+
const need = (s) => (kind(s) === 'int' ? `a whole number from ${s.min} to ${s.max}` : kind(s) === 'enum' ? `one of ${s.values.join(', ')}` : 'true or false');
|
|
43
|
+
const rank = (s, v) => (kind(s) === 'enum' ? s.values.indexOf(v) : Number(v));
|
|
44
|
+
|
|
45
|
+
export function createExtra(mods = EXTRA_MODULES) {
|
|
46
|
+
const owner = new Map();
|
|
47
|
+
for (const m of mods) for (const d of m.defs || []) { if (owner.has(d.name)) throw new Error(`extra tool ${d.name} registered twice`); owner.set(d.name, m); }
|
|
48
|
+
const settings = mods.flatMap((m) => m.settings || []);
|
|
49
|
+
const byKey = new Map(settings.map((s) => [s.key, s]));
|
|
50
|
+
const get = (cfg, key) => { const [g, k] = key.split('.'); const v = cfg?.[g]?.[k]; return valid(byKey.get(key), v) ? v : byKey.get(key).default; };
|
|
51
|
+
return {
|
|
52
|
+
defs: mods.flatMap((m) => m.defs || []),
|
|
53
|
+
has: (name) => owner.has(name),
|
|
54
|
+
async call(name, args, ctx) {
|
|
55
|
+
// A tool the person turned off (config disabledTools) is refused like the engine's own.
|
|
56
|
+
if (ctx?.policy && !ctx.policy.visible({ name })) return { content: [{ type: 'text', text: `Tool "${name}" is disabled on this computer.` }], isError: true, refused: true };
|
|
57
|
+
try {
|
|
58
|
+
return await owner.get(name).handle(name, args && typeof args === 'object' && !Array.isArray(args) ? args : {}, ctx);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
ctx?.log?.(`[bridgerator] ${name} failed: ${e?.code || ''} ${e?.message || e}`);
|
|
61
|
+
return { content: [{ type: 'text', text: errorText(e) }], isError: true };
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
settings,
|
|
65
|
+
/** Current values: { group: { name: bool } }. */
|
|
66
|
+
values(cfg) {
|
|
67
|
+
const o = {};
|
|
68
|
+
for (const s of settings) { const [g, k] = s.key.split('.'); (o[g] ||= {})[k] = get(cfg, s.key); }
|
|
69
|
+
return o;
|
|
70
|
+
},
|
|
71
|
+
defaults() {
|
|
72
|
+
const o = {};
|
|
73
|
+
for (const s of settings) { const [g, k] = s.key.split('.'); (o[g] ||= {})[k] = s.default; }
|
|
74
|
+
return o;
|
|
75
|
+
},
|
|
76
|
+
value: (cfg, key) => get(cfg, key),
|
|
77
|
+
/** → [{key, value}] from { group: { name: bool } } input, or { error }. Unknown groups → null (not ours). */
|
|
78
|
+
parse(input) {
|
|
79
|
+
const changes = [];
|
|
80
|
+
for (const [g, obj] of Object.entries(input || {})) {
|
|
81
|
+
if (!settings.some((s) => s.key.startsWith(`${g}.`))) continue;
|
|
82
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { error: `${g} must be an object` };
|
|
83
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
84
|
+
const s = byKey.get(`${g}.${k}`);
|
|
85
|
+
if (!s || !valid(s, v)) return { error: `${g}.${k}: unknown or not ${s ? need(s) : 'true/false'}` };
|
|
86
|
+
changes.push({ key: `${g}.${k}`, value: v });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { changes };
|
|
90
|
+
},
|
|
91
|
+
groups: () => [...new Set(settings.map((s) => s.key.split('.')[0]))],
|
|
92
|
+
/** JSON schema for the bridgerator_settings `settings` properties. */
|
|
93
|
+
schema() {
|
|
94
|
+
const o = {};
|
|
95
|
+
for (const s of settings) {
|
|
96
|
+
const [g, k] = s.key.split('.');
|
|
97
|
+
const p = kind(s) === 'int' ? { type: 'integer', minimum: s.min, maximum: s.max, description: `${s.label.en} (default ${s.default})` }
|
|
98
|
+
: kind(s) === 'enum' ? { type: 'string', enum: s.values, description: `${s.label.en} (default ${s.default})` }
|
|
99
|
+
: { type: 'boolean', description: `${s.label.en} (default ${s.default ? 'on' : 'off'})` };
|
|
100
|
+
(o[g] ||= { type: 'object', additionalProperties: false, properties: {} }).properties[k] = p;
|
|
101
|
+
}
|
|
102
|
+
return o;
|
|
103
|
+
},
|
|
104
|
+
owns: (key) => byKey.has(key),
|
|
105
|
+
widens(cfg, { key, value }) {
|
|
106
|
+
const s = byKey.get(key);
|
|
107
|
+
if (!s) return false;
|
|
108
|
+
if (kind(s) === 'boolean') return value === s.widensWhen && get(cfg, key) !== value;
|
|
109
|
+
if (!valid(s, value)) return false;
|
|
110
|
+
return s.widensWhen === 'up' ? rank(s, value) > rank(s, get(cfg, key)) : s.widensWhen === 'down' ? rank(s, value) < rank(s, get(cfg, key)) : false;
|
|
111
|
+
},
|
|
112
|
+
describe: ({ key, value }) => `${byKey.get(key)?.label.en || key}: ${typeof value === 'boolean' ? (value ? 'on' : 'off') : value}`,
|
|
113
|
+
/** Window / AI change → config.json (patch = bridge's patchConfig). true when the key is ours. */
|
|
114
|
+
set(cfg, key, value, patch) {
|
|
115
|
+
if (!byKey.has(key)) return false;
|
|
116
|
+
if (!valid(byKey.get(key), value)) throw new Error(`${key} must be ${need(byKey.get(key))}`);
|
|
117
|
+
const [g, k] = key.split('.');
|
|
118
|
+
patch({ [g]: { ...(cfg[g] || {}), [k]: value } });
|
|
119
|
+
return true;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/src/guard.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Mass-action fuse", "Keep awake").
|
|
2
|
+
// Mass-action fuse (off by default): more than `limit` deletions / moves within a minute → the bridge
|
|
3
|
+
// pauses itself and asks on the PC whether to continue (no answer = stays paused).
|
|
4
|
+
export const FUSE_DEFAULTS = { enabled: false, limit: 20 };
|
|
5
|
+
export const FUSE_WINDOW_MS = 60000;
|
|
6
|
+
|
|
7
|
+
export function createFuse({ now = Date.now, windowMs = FUSE_WINDOW_MS } = {}) {
|
|
8
|
+
let hits = []; // [{ t, n }]
|
|
9
|
+
const count = () => { const from = now() - windowMs; hits = hits.filter((h) => h.t > from); return hits.reduce((s, h) => s + h.n, 0); };
|
|
10
|
+
return {
|
|
11
|
+
/** Would n more actions go over the limit? → the total (with them) or 0 when they fit. Counted only if they fit. */
|
|
12
|
+
take(n, limit) {
|
|
13
|
+
const total = count() + n;
|
|
14
|
+
if (total > limit) return total;
|
|
15
|
+
hits.push({ t: now(), n });
|
|
16
|
+
return 0;
|
|
17
|
+
},
|
|
18
|
+
count,
|
|
19
|
+
reset() { hits = []; },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Keep the PC awake (on by default, AC power only — the tray helper checks the power line): while a
|
|
24
|
+
// call runs, for 2 minutes after the last one, and for 10 minutes after it while the task queue has
|
|
25
|
+
// work (ChatGPT thinks between calls). A queue nobody works on does not keep the PC up for ever.
|
|
26
|
+
export const AWAKE_AFTER_MS = 2 * 60000, AWAKE_QUEUE_MS = 10 * 60000;
|
|
27
|
+
export function wantAwake({ enabled = true, inFlight = 0, lastCall = 0, queue = {}, now = Date.now() } = {}) {
|
|
28
|
+
if (!enabled) return false;
|
|
29
|
+
if (inFlight > 0) return true;
|
|
30
|
+
if (!lastCall) return false;
|
|
31
|
+
const idle = now - lastCall;
|
|
32
|
+
if (idle < AWAKE_AFTER_MS) return true;
|
|
33
|
+
return ((queue.todo || 0) + (queue.doing || 0)) > 0 && idle < AWAKE_QUEUE_MS;
|
|
34
|
+
}
|
package/src/notice.mjs
CHANGED
|
@@ -1,39 +1,102 @@
|
|
|
1
|
-
// Plan notices from the relay (limit reached, Pro ending/ended)
|
|
2
|
-
// an "Open account" button. They never go into the ChatGPT
|
|
3
|
-
// The link is fixed here — the relay only says which
|
|
1
|
+
// Plan notices from the relay (limit reached, Pro ending/ended) and the bridge's own link notices,
|
|
2
|
+
// shown as a Windows notification with an "Open account" button. They never go into the ChatGPT
|
|
3
|
+
// chat (app rules: no upgrade links there). The link is fixed here — the relay only says which
|
|
4
|
+
// notice, not where to send the user. Relay values are validated strictly and reach PowerShell only
|
|
5
|
+
// as environment variables (escaped there), never as part of the script text.
|
|
4
6
|
import { execFile } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { POWERSHELL, psArgs } from './win.mjs';
|
|
9
|
+
import { LABEL } from './confirm.mjs';
|
|
10
|
+
import { safeUrl, accountUrl } from './plan.mjs';
|
|
5
11
|
|
|
6
12
|
export const ACCOUNT_URL = 'https://bridgerator.recondeck.com/app';
|
|
7
|
-
const
|
|
13
|
+
const int = (x) => (Number.isSafeInteger(x) && x >= 0 ? x : null);
|
|
14
|
+
const ymd = (x) => (typeof x === 'string' && /^\d{4}-\d{2}-\d{2}(T|$)/.test(x) ? x.slice(0, 10) : null);
|
|
8
15
|
const xml = (s) => String(s).replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
|
|
9
16
|
|
|
10
17
|
// ponytail: English only for now (owner, 20.10); other languages later.
|
|
11
18
|
export function noticeText(n) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
const kind = n?.kind;
|
|
20
|
+
let t;
|
|
21
|
+
if (kind === 'limit') {
|
|
22
|
+
const calls = int(n.calls), limit = int(n.limit);
|
|
23
|
+
if (calls === null || limit === null) return undefined;
|
|
24
|
+
t = ['Bridgerator: monthly limit reached', `${calls} of ${limit} tool calls used. ChatGPT can't use this PC until the 1st. Turn on Pro in your account.`];
|
|
25
|
+
} else if (kind === 'expiring') {
|
|
26
|
+
const until = ymd(n.until);
|
|
27
|
+
if (!until) return undefined;
|
|
28
|
+
t = [`Bridgerator Pro ends on ${until}`, 'Pay for 30 more days in your account to keep unlimited calls.'];
|
|
29
|
+
} else if (kind === 'queue_done') {
|
|
30
|
+
const title = typeof n.title === 'string' ? n.title.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, 120) : '';
|
|
31
|
+
if (!title) return undefined;
|
|
32
|
+
t = ['Queue task done', title];
|
|
33
|
+
} else if (kind === 'protection_off') {
|
|
34
|
+
// Internal list only (the bridge's own labels), never relay text.
|
|
35
|
+
const ok = new Set(['confirmations', 'your own patterns', ...Object.values(LABEL).map((l) => `confirmations for ${l}`)]);
|
|
36
|
+
const what = Array.isArray(n.what) ? n.what.filter((w) => ok.has(w)) : [];
|
|
37
|
+
if (!what.length) return undefined;
|
|
38
|
+
t = ['Bridgerator: protection turned off', `${what.join(', ')} — turn back on in the Bridgerator window or: bridgerator.cmd confirm on`];
|
|
39
|
+
} else if (kind === 'screenshot') {
|
|
40
|
+
// On every screenshot the AI takes (screen-tool.mjs): the person always sees that it happened.
|
|
41
|
+
const what = n.what === 'screen' ? 'Whole screen' : n.what === 'monitor' && int(n.monitor) ? `Monitor ${int(n.monitor)}`
|
|
42
|
+
: n.what === 'window' && typeof n.title === 'string' ? `Window "${n.title.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, 80)}"` : null;
|
|
43
|
+
if (!what) return undefined;
|
|
44
|
+
t = ['Bridgerator: the AI took a screenshot', `${what}${n.ocr ? ' (text recognized)' : ''}. You can turn screenshots or this notice off in the Bridgerator window.`];
|
|
45
|
+
return { title: t[0], body: t[1], button: null, url: accountUrl() };
|
|
46
|
+
} else if (kind === 'updating' || kind === 'updated') {
|
|
47
|
+
// In-window update (owner 21.10: never a console): before the hidden installer, and from the new version.
|
|
48
|
+
const v = typeof n.version === 'string' && /^\d+\.\d+\.\d+$/.test(n.version) ? n.version : null;
|
|
49
|
+
if (!v) return undefined;
|
|
50
|
+
t = kind === 'updating' ? [`Bridgerator is updating to ${v}`, 'It restarts by itself in about a minute. ChatGPT can use this PC again right after.'] : [`Bridgerator updated to ${v}`, 'Your settings are kept.'];
|
|
51
|
+
return { title: t[0], body: t[1], button: null, url: accountUrl() };
|
|
52
|
+
} else if (kind === 'confirm_locked') {
|
|
53
|
+
// V-9: the window / tray toggle could not write confirm.ini (another program holds it): nothing changed.
|
|
54
|
+
t = ['Bridgerator: the setting was not changed', 'Another program is using confirm.ini, so the confirmation setting stays as it was. Try again in a moment.'];
|
|
55
|
+
return { title: t[0], body: t[1], button: null, url: accountUrl() };
|
|
56
|
+
} else if (kind === 'confirm_off') {
|
|
57
|
+
const label = Object.hasOwn(LABEL, n.category) ? LABEL[n.category] : null;
|
|
58
|
+
if (!label) return undefined;
|
|
59
|
+
t = [`Confirmations for ${label} are off`, `Turn back on: bridgerator.cmd confirm on ${n.category}`];
|
|
60
|
+
} else {
|
|
61
|
+
const fixed = {
|
|
62
|
+
expired: ['Bridgerator Pro has ended', "You're on Free now (10,000 tool calls a month). Renew Pro in your account."],
|
|
63
|
+
unlinked: ['Bridgerator: this computer is no longer linked', 'ChatGPT can no longer reach this PC. Run the install command from your account page again.'],
|
|
64
|
+
offline: ['Bridgerator is offline', "ChatGPT can't reach this PC right now. Bridgerator keeps trying to reconnect."],
|
|
65
|
+
replaced: ['Bridgerator: this key is used elsewhere', 'Another Bridgerator with this computer\'s key took over the link. This PC retries later; if it keeps happening, run the install command on this PC again.'],
|
|
66
|
+
};
|
|
67
|
+
t = Object.hasOwn(fixed, kind) ? fixed[kind] : undefined;
|
|
68
|
+
}
|
|
69
|
+
if (!t) return undefined;
|
|
70
|
+
// Plan notices get the pay button (allowed here on the PC, never inside ChatGPT); only our own site.
|
|
71
|
+
const pay = { limit: 'Upgrade to Pro', expiring: 'Renew Pro', expired: 'Renew Pro' }[kind];
|
|
72
|
+
return { title: t[0], body: t[1], button: pay || 'Open account', url: pay ? safeUrl(n.renewUrl) : accountUrl() };
|
|
18
73
|
}
|
|
19
74
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
75
|
+
const ICON_URL = new URL('../assets/icon-64.png', import.meta.url);
|
|
76
|
+
export const ICON = fileURLToPath(ICON_URL);
|
|
77
|
+
export function toastXml({ title, body, button, url = accountUrl() }) {
|
|
78
|
+
const link = xml(safeUrl(url));
|
|
79
|
+
return `<toast activationType="protocol" launch="${link}"><visual><binding template="ToastGeneric"><image placement="appLogoOverride" src="${xml(ICON_URL.href)}"/><text>${xml(title)}</text><text>${xml(body)}</text></binding></visual>`
|
|
80
|
+
+ (button ? `<actions><action content="${xml(button)}" activationType="protocol" arguments="${link}"/></actions>` : '') + '</toast>';
|
|
23
81
|
}
|
|
24
82
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// Own app id under HKCU (no admin): the notification header reads "Bridgerator", not "Windows PowerShell".
|
|
31
|
-
const ps = `$k = 'HKCU:\\Software\\Classes\\AppUserModelId\\Bridgerator'
|
|
32
|
-
if (-not (Test-Path $k)) { New-Item $k -Force | Out-Null; New-ItemProperty $k -Name DisplayName -Value 'Bridgerator' -Force | Out-Null }
|
|
83
|
+
// Fixed script: the texts come in through BR_TOAST (the ready XML, escaped above).
|
|
84
|
+
const PS = `$k = 'HKCU:\\Software\\Classes\\AppUserModelId\\Bridgerator'
|
|
85
|
+
if (-not (Test-Path $k)) { New-Item $k -Force | Out-Null }
|
|
86
|
+
New-ItemProperty $k -Name DisplayName -Value 'Bridgerator' -Force | Out-Null
|
|
87
|
+
if ($env:BR_ICON -and (Test-Path $env:BR_ICON)) { New-ItemProperty $k -Name IconUri -Value $env:BR_ICON -Force | Out-Null }
|
|
33
88
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
|
34
89
|
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
|
35
90
|
$x = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
36
|
-
$x.LoadXml(
|
|
91
|
+
$x.LoadXml($env:BR_TOAST)
|
|
37
92
|
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Bridgerator').Show([Windows.UI.Notifications.ToastNotification]::new($x))`;
|
|
38
|
-
|
|
93
|
+
|
|
94
|
+
// `done` runs once the notification is shown (or could not be): lets the caller exit after it.
|
|
95
|
+
export function showNotice(n, log = console.log, done = () => {}) {
|
|
96
|
+
const t = noticeText(n);
|
|
97
|
+
if (!t) return done();
|
|
98
|
+
log(`[bridgerator] notice: ${t.title} — ${t.body} ${accountUrl()}`);
|
|
99
|
+
if (process.platform !== 'win32') return done();
|
|
100
|
+
// Own app id under HKCU (no admin): the notification header reads "Bridgerator", not "Windows PowerShell".
|
|
101
|
+
execFile(POWERSHELL, psArgs(PS), { windowsHide: true, timeout: 20000, env: { ...process.env, BR_TOAST: toastXml(t), BR_ICON: ICON } }, (e) => { if (e) log(`[bridgerator] notification failed: ${e.message}`); done(); });
|
|
39
102
|
}
|
package/src/pair.mjs
CHANGED
|
@@ -1,26 +1,48 @@
|
|
|
1
1
|
// Key-less setup: ask the relay for a pairing code, open the confirmation page in the browser, wait
|
|
2
2
|
// until the signed-in owner confirms this computer there, and get this PC's device key back.
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { CMD } from './win.mjs';
|
|
4
6
|
|
|
5
7
|
// wss://mcp.bridgerator.recondeck.com/link → https://bridgerator.recondeck.com (the site host, where the account pages live).
|
|
6
8
|
export const siteOf = (relay) => { const u = new URL(relay); return `${u.protocol === 'ws:' ? 'http' : 'https'}://${u.host.replace(/^mcp\./, '')}`; };
|
|
7
9
|
|
|
8
10
|
// Opens the URL in the default browser. Only a URL we checked ourselves reaches cmd's `start`.
|
|
9
11
|
export function openBrowser(url) {
|
|
10
|
-
if (!/^https
|
|
12
|
+
if (!/^https:\/\/[A-Za-z0-9.:-]+\/pair\?code=[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(url)) return;
|
|
11
13
|
try {
|
|
12
14
|
const p = process.platform === 'win32'
|
|
13
|
-
? spawn('
|
|
15
|
+
? spawn(CMD, ['/d', '/c', `start "" "${url}"`], { windowsHide: true, windowsVerbatimArguments: true, stdio: 'ignore', detached: true })
|
|
14
16
|
: spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { stdio: 'ignore', detached: true });
|
|
15
17
|
p.on('error', () => {});
|
|
16
18
|
p.unref();
|
|
17
19
|
} catch { /* no browser: the URL is printed */ }
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
/** → { token, name } once the owner confirmed; throws when the code expires. */
|
|
21
|
-
|
|
22
|
+
/** → { token, name, email? } once the owner confirmed; throws when the code expires. */
|
|
23
|
+
/**
|
|
24
|
+
* This PC + this Windows user, as a one-way fingerprint: sha256 hex of MachineGuid + user SID. Lets
|
|
25
|
+
* the relay replace an offline PC of the same name only when it is really the same machine/user.
|
|
26
|
+
* The raw values never leave the PC. → '' when either is unknown.
|
|
27
|
+
*/
|
|
28
|
+
export function machineFp(machineGuid, sid) {
|
|
29
|
+
const g = String(machineGuid || '').trim().toLowerCase(), u = String(sid || '').trim().toUpperCase();
|
|
30
|
+
if (!/^[0-9a-f-]{32,36}$/.test(g) || !/^S-1-[\d-]+$/.test(u)) return '';
|
|
31
|
+
return crypto.createHash('sha256').update(`${g}|${u}`).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The key travels over these requests: https only (the same "wss only" rule as the link itself);
|
|
35
|
+
// plain http only to this PC itself (loopback: local tests, a relay on the same machine).
|
|
36
|
+
const needHttps = (site) => {
|
|
37
|
+
const s = String(site || '');
|
|
38
|
+
if (/^https:\/\/[^\s/]+/i.test(s) || /^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/i.test(s)) return;
|
|
39
|
+
throw new Error(`the server address must be https://, got "${site}"`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export async function pair(site, name, { fp = '', fetch: f = globalThis.fetch, open = openBrowser, log = console.log, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), everyMs = 3000 } = {}) {
|
|
43
|
+
needHttps(site);
|
|
22
44
|
const post = (path, body) => f(`${site}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(20000) });
|
|
23
|
-
const r = await post('/pair/start', { name });
|
|
45
|
+
const r = await post('/pair/start', { name, ...(fp ? { fp } : {}) });
|
|
24
46
|
if (!r.ok) throw new Error(`could not start pairing (HTTP ${r.status}) — try again in a minute`);
|
|
25
47
|
const s = await r.json();
|
|
26
48
|
const url = `${site}/pair?code=${s.code}`;
|
|
@@ -36,8 +58,21 @@ export async function pair(site, name, { fetch: f = globalThis.fetch, open = ope
|
|
|
36
58
|
await sleep(everyMs);
|
|
37
59
|
let p;
|
|
38
60
|
try { p = await post('/pair/poll', { poll: s.poll }); } catch { continue; } // network blip: try again
|
|
39
|
-
if (p.status === 200) { const j = await p.json(); if (j.token) return { token: j.token, name: j.name || name }; }
|
|
61
|
+
if (p.status === 200) { const j = await p.json(); if (j.token) return { token: j.token, name: j.name || name, ...(typeof j.email === 'string' ? { email: j.email } : {}) }; }
|
|
40
62
|
if (p.status === 410) break;
|
|
63
|
+
if (p.status === 404) throw new Error('this pairing code is no longer valid — run the install command again');
|
|
64
|
+
if (p.status === 429) throw new Error('too many attempts — wait a few minutes, then run the install command again');
|
|
41
65
|
}
|
|
42
66
|
throw new Error('the code expired before it was confirmed — run the command again');
|
|
43
67
|
}
|
|
68
|
+
|
|
69
|
+
/** Is the stored device key still accepted? → 'ok' | 'rejected' (computer removed in the account) | 'unknown' (network). */
|
|
70
|
+
export async function checkKey(site, token, { fetch: f = globalThis.fetch } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
needHttps(site);
|
|
73
|
+
const r = await f(`${site}/pair/check`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token }), signal: AbortSignal.timeout(15000) });
|
|
74
|
+
if (r.status === 401) return 'rejected';
|
|
75
|
+
if (r.status === 200 && (await r.json().catch(() => ({}))).ok === true) return 'ok';
|
|
76
|
+
} catch { /* offline: keep the key */ }
|
|
77
|
+
return 'unknown';
|
|
78
|
+
}
|