summrise-agent 1.2.509 → 1.2.511
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/bin/summrise.exe +0 -0
- package/package.json +11 -8
- package/summrise-agent.exe +0 -0
- package/summrise-desktop-electron/src/main.js +400 -354
- package/summrise-desktop-electron/src/preload.js +4 -1
- package/summrise-desktop-electron/src/summrise_shell_policy.js +1280 -0
- package/summrise-desktop-electron/src/summrise_shell_policy_bg.wasm +0 -0
- package/summrise-desktop-electron/src/summrise_url_policy.js +404 -0
- package/summrise-desktop-electron/src/summrise_url_policy_bg.wasm +0 -0
- package/summrise-launch.exe +0 -0
- package/bin/summrise.js +0 -3906
- package/summrise-desktop-electron/src/url-policy.js +0 -238
|
@@ -57,9 +57,35 @@ const electron_1 = require("electron");
|
|
|
57
57
|
const path = __importStar(require("path"));
|
|
58
58
|
const fs = __importStar(require("fs"));
|
|
59
59
|
const http = __importStar(require("http"));
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
60
|
+
// THE POLICY IS RUST, IN TWO CRATES, AND THIS FILE IS THE HOST THAT CALLS IT.
|
|
61
|
+
//
|
|
62
|
+
// `agent/summrise-url-policy` (landing 6a) owns the URL/origin/certificate decisions; `cargo test -p
|
|
63
|
+
// summrise-url-policy` pins them. `agent/summrise-shell-policy` (landing 6b) owns everything else this
|
|
64
|
+
// file used to decide: the device token's line pattern, the auth header, the port precedence, the icon
|
|
65
|
+
// per platform, the native menu table, the browser-session plan and both embedded views' doors, the
|
|
66
|
+
// loopback control server's router, the schtasks arguments, the watchdog, the tripwire, the CDP
|
|
67
|
+
// self-check and every string the tray shows. `cargo test -p summrise-shell-policy` pins those.
|
|
68
|
+
//
|
|
69
|
+
// BOTH are built to wasm with `wasm-pack --target nodejs` and required here; the glue compiles each
|
|
70
|
+
// module SYNCHRONOUSLY, so the first decision may run immediately — which matters, because the first
|
|
71
|
+
// thing this file does is pin the agent's port. The glues and the modules are committed and shipped
|
|
72
|
+
// (agent/summrise-agent-npm/required-in-tgz.txt names all four files, and `summrise update` stages them).
|
|
73
|
+
//
|
|
74
|
+
// **THE TWO CRATES DO NOT DEPEND ON EACH OTHER, AND THAT IS A MEASURED CONSTRAINT RATHER THAN A STYLE.**
|
|
75
|
+
// wasm-bindgen exports every `#[wasm_bindgen]` item in the whole crate GRAPH, so making the shell-policy
|
|
76
|
+
// crate depend on the url-policy one made its glue re-export all 18 url-policy names — and each glue
|
|
77
|
+
// compiles its OWN `.wasm`, so url-policy's `thread_local!` port state would exist TWICE.
|
|
78
|
+
// `setAgentPort(7740)` through one module would leave the other at 18080: a custom-port install where the
|
|
79
|
+
// DSH door admits the wrong origin and the tripwire snaps the panel back. So where a decision below
|
|
80
|
+
// composes a url-policy predicate it passes that predicate's ANSWER in — `isDshUrl(raw)`,
|
|
81
|
+
// `isDesktopSpaUrl(url)`, `isBaseOrigin(url)`, `parseAgentPort(raw)`, `sanitizeBrowserUrl(raw)` — and
|
|
82
|
+
// this file, which holds exactly one instance of each glue, evaluates it.
|
|
83
|
+
const summrise_url_policy_1 = require("./summrise_url_policy");
|
|
84
|
+
const summrise_shell_policy_1 = require("./summrise_shell_policy");
|
|
85
|
+
const K = JSON.parse((0, summrise_shell_policy_1.shellConstants)());
|
|
86
|
+
// The handful the shell reads often enough that `K.` at every use site would be noise. They are still the
|
|
87
|
+
// table's values — destructuring a copy of the parse, not a second declaration.
|
|
88
|
+
const { AGENT_TASK, SCHTASKS_TIMEOUT_MS, AGENT_PROBE_DEFAULT_MS, AGENT_PROBE_BOOT_MS, AGENT_PROBE_TRAY_MS, CONTROL_PROBE_MS, STATUS_FETCH_MS, HARNESS_FETCH_MS, CDP_CHECK_MS, MENU_FLUSH_MS, TRAY_POLL_MS, RETRY_START_MS, TRIPWIRE_BACKOFF_MS, } = K;
|
|
63
89
|
// IPC audit #3: /api/status is TOKEN-GATED (same fact the watchdog fix cites);
|
|
64
90
|
// credential-less fetches got 401 -> version title + tray vitals were DEAD on
|
|
65
91
|
// every configured device. The shell runs as the interactive admin, and the
|
|
@@ -72,47 +98,44 @@ const url_policy_1 = require("./url-policy");
|
|
|
72
98
|
const INSTALL_ROOT = path.join(__dirname, "..", "..", "..");
|
|
73
99
|
let _tokenCache = { at: 0, tok: null };
|
|
74
100
|
function agentToken() {
|
|
75
|
-
if (
|
|
101
|
+
if ((0, summrise_shell_policy_1.tokenCacheFresh)(_tokenCache.at, Date.now()))
|
|
76
102
|
return _tokenCache.tok;
|
|
77
|
-
let
|
|
103
|
+
let raw;
|
|
78
104
|
try {
|
|
79
|
-
|
|
80
|
-
// THIS PATTERN IS NARROWER THAN THE TWO OTHER PARSERS OF THE SAME LINE: the CLI's
|
|
81
|
-
// (bin/summrise.js, `[A-Za-z0-9._-]+`) and the agent's Rust recovery both accept more than
|
|
82
|
-
// lowercase hex. Measured against a live device on 2026-09-24 — the token is 64 hex characters
|
|
83
|
-
// with no space before the colon, so all three agree TODAY, and hex is what the worker issues.
|
|
84
|
-
// The failure mode if that ever stops being true is SILENT: no credential, a 401, a title that
|
|
85
|
-
// stays "Summrise" and vitals that stay blank — the state the comment above claims to have
|
|
86
|
-
// fixed. Widen this line before debugging that.
|
|
87
|
-
const m = /device_token:\s*"?([0-9a-f]{16,})"?/.exec(raw);
|
|
88
|
-
if (m)
|
|
89
|
-
tok = m[1];
|
|
105
|
+
raw = fs.readFileSync(path.join(INSTALL_ROOT, "etc", "config.yaml"), "utf8");
|
|
90
106
|
}
|
|
91
107
|
catch { /* no local config — vitals stay hidden, same as before */ }
|
|
108
|
+
// THE PATTERN IS THE POLICY CRATE'S, and it is deliberately NARROWER than the two other parsers of
|
|
109
|
+
// this line: the CLI's (bin/summrise.js, `[A-Za-z0-9._-]+`) and the agent's Rust recovery both accept
|
|
110
|
+
// more than lowercase hex. Measured against a live device on 2026-09-24 — the token is 64 hex
|
|
111
|
+
// characters with no space before the colon, so all three agree TODAY, and hex is what the worker
|
|
112
|
+
// issues. The failure mode if that ever stops being true is SILENT: no credential, a 401, a title that
|
|
113
|
+
// stays "Summrise" and vitals that stay blank. `cargo test -p summrise-shell-policy` pins which side of
|
|
114
|
+
// that divergence the pattern is on; WIDEN IT THERE before debugging this.
|
|
115
|
+
const tok = (0, summrise_shell_policy_1.deviceToken)(raw) ?? null;
|
|
92
116
|
_tokenCache = { at: Date.now(), tok };
|
|
93
117
|
return tok;
|
|
94
118
|
}
|
|
95
119
|
function authHeaders() {
|
|
96
|
-
const
|
|
97
|
-
return
|
|
120
|
+
const value = (0, summrise_shell_policy_1.authorization)(agentToken());
|
|
121
|
+
return value ? { authorization: value } : {};
|
|
98
122
|
}
|
|
99
123
|
// Agent bind port (custom-port installs): explicit SUMMRISE_AGENT_PORT env
|
|
100
124
|
// first, then the agent's config.yaml server.port next to the install dir
|
|
101
125
|
// (same file agentToken() reads — sync fs, same best-effort discipline),
|
|
102
126
|
// else the canonical 18080. Resolved once at boot before any probe/window;
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
127
|
+
// the Rust policy's predicates follow via setAgentPort (which ignores an
|
|
128
|
+
// invalid port rather than resetting to the default).
|
|
129
|
+
function bootAgentPort() {
|
|
130
|
+
// The ORDER (env, then the device's own config, then the canonical port) is the policy crate's; the
|
|
131
|
+
// YAML walk that produces the middle answer is the URL policy's, which is why it is evaluated HERE and
|
|
132
|
+
// passed in. Both reads are best-effort and both fall through to the next source.
|
|
133
|
+
let fromConfig;
|
|
108
134
|
try {
|
|
109
|
-
|
|
110
|
-
const port = (0, url_policy_1.parseAgentPort)(raw);
|
|
111
|
-
if (port)
|
|
112
|
-
return port;
|
|
135
|
+
fromConfig = (0, summrise_url_policy_1.parseAgentPort)(fs.readFileSync(path.join(INSTALL_ROOT, "etc", "config.yaml"), "utf8")) ?? undefined;
|
|
113
136
|
}
|
|
114
137
|
catch { /* no local config — default below */ }
|
|
115
|
-
return
|
|
138
|
+
return (0, summrise_shell_policy_1.resolveAgentPort)(process.env.SUMMRISE_AGENT_PORT, fromConfig);
|
|
116
139
|
}
|
|
117
140
|
// Remote-verifiable icon facts (GET /api/shell/icon-status on the 9444
|
|
118
141
|
// loopback control server): file blind-flying on icon issues ended here —
|
|
@@ -147,10 +170,12 @@ function resolveIcon(name, reportKey) {
|
|
|
147
170
|
// silently falling back to the stock electron.exe icon, device-caught).
|
|
148
171
|
// Empty string when absent — callers fall back to Electron defaults.
|
|
149
172
|
function appIcon() {
|
|
150
|
-
|
|
173
|
+
// WHICH FILE is the policy crate's decision (`icon.ico` for the Windows tray, `icon.png` everywhere
|
|
174
|
+
// else); resolving and reporting it is this file's.
|
|
175
|
+
return resolveIcon((0, summrise_shell_policy_1.trayIconName)(process.platform), "tray");
|
|
151
176
|
}
|
|
152
177
|
function windowIcon() {
|
|
153
|
-
return resolveIcon(
|
|
178
|
+
return resolveIcon((0, summrise_shell_policy_1.windowIconName)(), "window");
|
|
154
179
|
}
|
|
155
180
|
// Native-decode probe: file-exists is NOT proof Electron can use the
|
|
156
181
|
// image (a corrupt/undecodable file falls back silently). Report what
|
|
@@ -170,17 +195,16 @@ function probeImage(p) {
|
|
|
170
195
|
// IPC audit #2: preload runs in EVERY frame; will-navigate never gated
|
|
171
196
|
// iframes. Handlers must reject anything not sourced from the pinned panel.
|
|
172
197
|
function frameOk(e) {
|
|
173
|
-
return (0,
|
|
198
|
+
return (0, summrise_url_policy_1.frameUrlOk)(e.senderFrame?.url || "");
|
|
174
199
|
}
|
|
175
200
|
// THE ONE REFUSAL a forbidden frame gets. It used to have TWO shapes — seven
|
|
176
201
|
// handlers answered this one and the rest answered a bare `{ ok: false }`,
|
|
177
202
|
// which the SPA cannot tell apart from a dead view (`j?.ok` is falsy either
|
|
178
203
|
// way, and only this shape carries the reason). The preload's `invoke` callers
|
|
179
204
|
// were written against THIS shape, so every handler answers it now.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
});
|
|
205
|
+
// THE VALUE IS THE POLICY CRATE'S, so the one refusal has one owner. `Object.freeze` is kept here
|
|
206
|
+
// because it is this file's guarantee to itself, not a decision about what the SPA reads.
|
|
207
|
+
const FORBIDDEN_FRAME = Object.freeze(JSON.parse((0, summrise_shell_policy_1.forbiddenFrame)()));
|
|
184
208
|
// THE ONE IPC DOOR: `ipcMain.handle` is called HERE and nowhere else, so the
|
|
185
209
|
// frame check above is applied ONCE per channel and no handler can forget it.
|
|
186
210
|
// A fourteenth handler that re-checked (or skipped) the frame was SILENT:
|
|
@@ -201,10 +225,12 @@ function ipcHandle(channel, fn) {
|
|
|
201
225
|
// P1: CDP port for AI (playwright) to drive Summrise's own pages — the SAME
|
|
202
226
|
// Electron window the user watches. Summrise's playwright-mcp connects via
|
|
203
227
|
// connectOverCDP("http://127.0.0.1:9333") and drives this window's pages.
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
228
|
+
// P1b: CTRL_PORT is the fallback local control endpoint for browser sessions
|
|
229
|
+
// (used when the SPA runs in a plain browser, not under the Electron preload IPC).
|
|
230
|
+
// BOTH NUMBERS ARE THE POLICY CRATE'S (`K.CDP_PORT`, `K.CTRL_PORT`) and NOTHING about when or whether
|
|
231
|
+
// they are opened moved with them: the `remote-debugging-port` switch below is still the only thing that
|
|
232
|
+
// opens 9333, and 9333 is a product contract — `agent/tests/mcp_autoselect_integration.rs` binds it, the
|
|
233
|
+
// CLI probes it, and the design sweep's attached mode depends on it.
|
|
208
234
|
let win = null;
|
|
209
235
|
let tray = null;
|
|
210
236
|
/** Whether the hide-to-tray notification has been shown (once per launch). */
|
|
@@ -214,8 +240,8 @@ const browserSessions = new Map();
|
|
|
214
240
|
* webContents.getURL() (which lags during navigation; stage-n). */
|
|
215
241
|
const browserTargets = new Map();
|
|
216
242
|
/** Cap on concurrent browser-session windows — an AI loop opening sessions
|
|
217
|
-
* repeatedly must not pile up windows on the desktop (stage-n).
|
|
218
|
-
|
|
243
|
+
* repeatedly must not pile up windows on the desktop (stage-n). The number is `K.MAX_BROWSER_WINDOWS`;
|
|
244
|
+
* the PLAN that applies it (reuse by initial target, then evict the oldest) is the policy crate's. */
|
|
219
245
|
// stage-m: SINGLE-INSTANCE LOCK — a second Summrise window exits immediately.
|
|
220
246
|
// (The agent itself also enforces single-instance via bind-failure exit.)
|
|
221
247
|
const gotTheLock = electron_1.app.requestSingleInstanceLock();
|
|
@@ -226,12 +252,12 @@ else {
|
|
|
226
252
|
electron_1.app.on("second-instance", () => { focusMain(); });
|
|
227
253
|
}
|
|
228
254
|
// CDP must be enabled before app ready — pass it through Chromium switches.
|
|
229
|
-
electron_1.app.commandLine.appendSwitch("remote-debugging-port", String(CDP_PORT));
|
|
255
|
+
electron_1.app.commandLine.appendSwitch("remote-debugging-port", String(K.CDP_PORT));
|
|
230
256
|
// Lab-device self-signed certs (OpenWrt-style ONT defaults): bypass cert
|
|
231
257
|
// errors ONLY on private-network hosts — the public internet keeps full
|
|
232
258
|
// validation. Registered before ready so no navigation can race it.
|
|
233
259
|
electron_1.app.on("certificate-error", (event, _webContents, url, _error, _certificate, callback) => {
|
|
234
|
-
if ((0,
|
|
260
|
+
if ((0, summrise_url_policy_1.certBypassAllowed)(String(url || ""))) {
|
|
235
261
|
event.preventDefault();
|
|
236
262
|
callback(true);
|
|
237
263
|
return;
|
|
@@ -301,77 +327,34 @@ function focusMain() {
|
|
|
301
327
|
win.show();
|
|
302
328
|
win.focus();
|
|
303
329
|
}
|
|
304
|
-
/** Build the native application menu.
|
|
305
|
-
*
|
|
330
|
+
/** Build the native application menu.
|
|
331
|
+
*
|
|
332
|
+
* THE TABLE IS THE POLICY CRATE'S — every label, every accelerator and every command id — because the
|
|
333
|
+
* command ids are the contract with the SPA's own `summrise-menu` listener and the accelerators are the
|
|
334
|
+
* second half of it (the SPA handles the same chords from its own keydown map in a plain browser). A
|
|
335
|
+
* renamed id or a dropped row breaks the desktop app and nothing else. `Menu.buildFromTemplate` and the
|
|
336
|
+
* Electron `role` strings are this file's. */
|
|
306
337
|
function buildMenu() {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
{ label: "New Browser Session…", accelerator: "CmdOrCtrl+Shift+B", click: () => sendMenu("new-browser") },
|
|
328
|
-
{ type: "separator" },
|
|
329
|
-
{ label: "Close Session", accelerator: "CmdOrCtrl+W", click: () => sendMenu("close-session") },
|
|
330
|
-
{ type: "separator" },
|
|
331
|
-
isMac ? { role: "close" } : { role: "quit", label: "Exit" },
|
|
332
|
-
],
|
|
333
|
-
},
|
|
334
|
-
{
|
|
335
|
-
label: "Edit",
|
|
336
|
-
submenu: [
|
|
337
|
-
{ role: "undo" },
|
|
338
|
-
{ role: "redo" },
|
|
339
|
-
{ type: "separator" },
|
|
340
|
-
{ role: "cut" },
|
|
341
|
-
{ role: "copy" },
|
|
342
|
-
{ role: "paste" },
|
|
343
|
-
{ role: "selectAll" },
|
|
344
|
-
],
|
|
345
|
-
},
|
|
346
|
-
{
|
|
347
|
-
label: "View",
|
|
348
|
-
submenu: [
|
|
349
|
-
{ label: "Toggle Trajectory", accelerator: "CmdOrCtrl+Shift+Y", click: () => sendMenu("toggle-trajectory") },
|
|
350
|
-
{ type: "separator" },
|
|
351
|
-
{ label: "Reload", accelerator: "CmdOrCtrl+R", click: () => { if (win)
|
|
352
|
-
win.webContents.reload(); } },
|
|
353
|
-
{ role: "togglefullscreen" },
|
|
354
|
-
{ role: "resetZoom" },
|
|
355
|
-
{ role: "zoomIn" },
|
|
356
|
-
{ role: "zoomOut" },
|
|
357
|
-
{ role: "toggleDevTools" },
|
|
358
|
-
],
|
|
359
|
-
},
|
|
360
|
-
{
|
|
361
|
-
label: "Session",
|
|
362
|
-
submenu: [
|
|
363
|
-
{ label: "Next Session", accelerator: "Ctrl+Tab", click: () => sendMenu("next-session") },
|
|
364
|
-
{ label: "Previous Session", accelerator: "Ctrl+Shift+Tab", click: () => sendMenu("prev-session") },
|
|
365
|
-
// IPC audit #6: the "List Sessions" entry dispatched 'list-sessions',
|
|
366
|
-
// a command with NO SPA-side handler — deleted (dead menu surface).
|
|
367
|
-
// The "Export Session Log…" entry (^⇧E) was deleted for the same kind of
|
|
368
|
-
// reason read the other way: the per-tab export mark IS the action
|
|
369
|
-
// (TabBar's .tab-export, one mark per session, so it exports what you
|
|
370
|
-
// point at rather than whatever happens to be active). The accelerator
|
|
371
|
-
// is still handled by the SPA's own keydown map in a plain browser.
|
|
372
|
-
],
|
|
373
|
-
},
|
|
374
|
-
];
|
|
338
|
+
const template = JSON.parse((0, summrise_shell_policy_1.appMenuJson)(process.platform, electron_1.app.name)).map((section) => ({
|
|
339
|
+
label: section.label,
|
|
340
|
+
submenu: section.items.map((item) => {
|
|
341
|
+
if (item.separator)
|
|
342
|
+
return { type: "separator" };
|
|
343
|
+
const base = {};
|
|
344
|
+
if (item.label)
|
|
345
|
+
base.label = item.label;
|
|
346
|
+
if (item.accelerator)
|
|
347
|
+
base.accelerator = item.accelerator;
|
|
348
|
+
// THREE KINDS OF ROW, and the crate's table says which is which: a command the SPA dispatches, the
|
|
349
|
+
// ONE host method (Reload), and an Electron role the menu system implements itself.
|
|
350
|
+
if (item.command)
|
|
351
|
+
return { ...base, click: () => sendMenu(item.command) };
|
|
352
|
+
if (item.reload)
|
|
353
|
+
return { ...base, click: () => { if (win)
|
|
354
|
+
win.webContents.reload(); } };
|
|
355
|
+
return { ...base, role: item.role };
|
|
356
|
+
}),
|
|
357
|
+
}));
|
|
375
358
|
return electron_1.Menu.buildFromTemplate(template);
|
|
376
359
|
}
|
|
377
360
|
// --- Browser-session control: shared core (used by both IPC and HTTP) ---
|
|
@@ -383,9 +366,9 @@ function buildMenu() {
|
|
|
383
366
|
* collapse to "about:blank", because a loadable file:// would hand the AI a
|
|
384
367
|
* local-file read primitive via the shared CDP endpoint (:9333).
|
|
385
368
|
*
|
|
386
|
-
* The DECISION itself is
|
|
387
|
-
* unit-tested in
|
|
388
|
-
* no node suite can import it. This function is that predicate's ONE call site
|
|
369
|
+
* The DECISION itself is the Rust policy's sanitizeBrowserUrl() — pure, and
|
|
370
|
+
* unit-tested in agent/summrise-url-policy (src/tests.rs) because main.ts
|
|
371
|
+
* imports electron and no node suite can import it. This function is that predicate's ONE call site
|
|
389
372
|
* in the shell (test/ipc-door.test.mjs pins the count), so a fourth load site
|
|
390
373
|
* cannot quietly reach for a weaker policy of its own.
|
|
391
374
|
*
|
|
@@ -393,35 +376,36 @@ function buildMenu() {
|
|
|
393
376
|
* window, embeddedNavigate() loads it, and the embedded view's window-open
|
|
394
377
|
* handler REFUSES it (an external link must not blank the page being read). */
|
|
395
378
|
function loadTarget(raw) {
|
|
396
|
-
return (0,
|
|
379
|
+
return (0, summrise_url_policy_1.sanitizeBrowserUrl)(raw);
|
|
397
380
|
}
|
|
398
381
|
/** Open a browser-session window on a decided load target (loadTarget() is the
|
|
399
382
|
* ONE load door — the scheme policy lives there). */
|
|
400
383
|
function browserOpen(url) {
|
|
401
384
|
const target = loadTarget(url);
|
|
402
|
-
// stage-n: reuse an existing window on the same URL instead of stacking
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
385
|
+
// stage-n: reuse an existing window on the same URL instead of stacking duplicates (AI-driven
|
|
386
|
+
// browsing opens/closes sessions repeatedly). Match against the window's INITIAL target (stored at
|
|
387
|
+
// open) — webContents.getURL() lags during navigation (returns about:blank while loading), so a
|
|
388
|
+
// same-URL reopen right after the first open would otherwise miss the reuse and stack a duplicate.
|
|
389
|
+
//
|
|
390
|
+
// THE PLAN IS THE POLICY CRATE'S, in one call: which window to reuse (first non-destroyed match, in
|
|
391
|
+
// INSERTION order), and — when the cap is reached — which one to evict (the OLDEST, not the least
|
|
392
|
+
// recently used). This file only reads the session table and applies the answer. The `destroyed` flag
|
|
393
|
+
// travels with the row because a destroyed window is skipped for reuse while still COUNTING against
|
|
394
|
+
// the cap, which is what `browserSessions.size` did.
|
|
395
|
+
const plan = JSON.parse((0, summrise_shell_policy_1.planBrowserOpen)(JSON.stringify([...browserSessions].map(([id, bw]) => ({ id, target: browserTargets.get(id) ?? "", destroyed: bw.isDestroyed() }))), target, K.MAX_BROWSER_WINDOWS));
|
|
396
|
+
if (plan.reuse) {
|
|
397
|
+
const existing = browserSessions.get(plan.reuse);
|
|
398
|
+
if (existing) {
|
|
399
|
+
if (existing.isMinimized())
|
|
400
|
+
existing.restore();
|
|
401
|
+
existing.show();
|
|
402
|
+
existing.focus();
|
|
403
|
+
return { ok: true, id: plan.reuse, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
|
|
415
404
|
}
|
|
416
405
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const oldest = browserSessions.keys().next().value;
|
|
421
|
-
if (oldest)
|
|
422
|
-
browserClose(oldest);
|
|
423
|
-
}
|
|
424
|
-
const id = `browser-${Date.now()}`;
|
|
406
|
+
if (plan.evict)
|
|
407
|
+
browserClose(plan.evict);
|
|
408
|
+
const id = (0, summrise_shell_policy_1.browserId)(Date.now());
|
|
425
409
|
const bw = new electron_1.BrowserWindow({
|
|
426
410
|
width: 1100, height: 750, title: `Summrise Browser — ${target}`,
|
|
427
411
|
...(windowIcon() ? { icon: windowIcon() } : {}),
|
|
@@ -443,7 +427,7 @@ function browserOpen(url) {
|
|
|
443
427
|
});
|
|
444
428
|
browserSessions.set(id, bw);
|
|
445
429
|
browserTargets.set(id, target);
|
|
446
|
-
return { ok: true, id, url: target, cdp:
|
|
430
|
+
return { ok: true, id, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
|
|
447
431
|
}
|
|
448
432
|
function browserClose(id) {
|
|
449
433
|
const bw = browserSessions.get(id || "");
|
|
@@ -460,7 +444,7 @@ function browserClose(id) {
|
|
|
460
444
|
}
|
|
461
445
|
function browserList() {
|
|
462
446
|
const list = [...browserSessions.entries()].map(([id, bw]) => ({ id, url: bw.webContents.getURL() }));
|
|
463
|
-
return { ok: true, sessions: list, cdp:
|
|
447
|
+
return { ok: true, sessions: list, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
|
|
464
448
|
}
|
|
465
449
|
ipcHandle("browser-session:open", (url) => browserOpen(url));
|
|
466
450
|
ipcHandle("browser-session:close", (id) => browserClose(id));
|
|
@@ -506,9 +490,13 @@ function embeddedViewEnsure() {
|
|
|
506
490
|
// anything it refuses (javascript:, file:, chrome:) must NOT blank the page
|
|
507
491
|
// the user is reading, so a refused target is dropped here instead.
|
|
508
492
|
view.webContents.setWindowOpenHandler(({ url }) => {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
493
|
+
// THE DOOR DECIDES, THIS FILE APPLIES: `loadTarget` is the ONE load door (the URL policy's), and
|
|
494
|
+
// the DECISION to DROP a refused target rather than navigate to the blank page is the policy
|
|
495
|
+
// crate's — a refused `javascript:`/`file:`/`chrome:` link must not erase what the operator is
|
|
496
|
+
// reading, which is why a refusal is `undefined` here and not `"about:blank"`.
|
|
497
|
+
const decided = (0, summrise_shell_policy_1.embeddedPopupTarget)(loadTarget(url));
|
|
498
|
+
if (decided !== undefined) {
|
|
499
|
+
embeddedNavigate(decided);
|
|
512
500
|
}
|
|
513
501
|
return { action: "deny" };
|
|
514
502
|
});
|
|
@@ -536,7 +524,10 @@ function embeddedViewPlace(bounds) {
|
|
|
536
524
|
const view = embeddedViewEnsure();
|
|
537
525
|
if (!win)
|
|
538
526
|
return;
|
|
539
|
-
|
|
527
|
+
// `K.MIN_SLOT_PX` AND ITS COMPARISON ARE ONE FUNCTION NOW: this view and the DSH view each spelled
|
|
528
|
+
// `bounds.width < 50 || bounds.height < 50` BEFORE the port, which is two copies of one policy — the
|
|
529
|
+
// place where the browser view and the DSH view would come to disagree about what "showing" means.
|
|
530
|
+
if ((0, summrise_shell_policy_1.slotTooSmall)(bounds)) {
|
|
540
531
|
view.setVisible(false);
|
|
541
532
|
embeddedVisible = false;
|
|
542
533
|
return;
|
|
@@ -564,7 +555,7 @@ function embeddedGo(delta) {
|
|
|
564
555
|
if (!view || view.webContents.isDestroyed())
|
|
565
556
|
return { ok: false };
|
|
566
557
|
try {
|
|
567
|
-
if (
|
|
558
|
+
if ((0, summrise_shell_policy_1.goBackwards)(delta))
|
|
568
559
|
view.webContents.goBack();
|
|
569
560
|
else
|
|
570
561
|
view.webContents.goForward();
|
|
@@ -591,11 +582,13 @@ function embeddedState() {
|
|
|
591
582
|
const wc = view && !view.webContents.isDestroyed() ? view.webContents : null;
|
|
592
583
|
return {
|
|
593
584
|
ok: true,
|
|
594
|
-
|
|
585
|
+
// The FALLBACK is the policy crate's: a live `getURL()` that answers "" (not yet navigated) and no
|
|
586
|
+
// live contents at all are the same answer, and it is the last URL this view was sent to.
|
|
587
|
+
url: (0, summrise_shell_policy_1.shownUrl)(wc ? wc.getURL() : undefined, embeddedUrl),
|
|
595
588
|
canBack: !!wc && wc.navigationHistory.canGoBack(),
|
|
596
589
|
canFwd: !!wc && wc.navigationHistory.canGoForward(),
|
|
597
590
|
title: wc ? wc.getTitle() : "",
|
|
598
|
-
visible: embeddedVisible
|
|
591
|
+
visible: (0, summrise_shell_policy_1.viewVisible)(embeddedVisible, !!wc),
|
|
599
592
|
};
|
|
600
593
|
}
|
|
601
594
|
/** round-247: push real navigation state (URL + title + history) to the SPA
|
|
@@ -656,8 +649,9 @@ function embeddedRecover() {
|
|
|
656
649
|
// Already decided by loadTarget() when embeddedUrl was set (the ONE load
|
|
657
650
|
// door) — recovery re-loads that decision, it does not re-open the policy.
|
|
658
651
|
// The fallback is about:blank, NOT a third-party home page: recovering a
|
|
659
|
-
// crashed view must not make a request to a search engine to do it.
|
|
660
|
-
|
|
652
|
+
// crashed view must not make a request to a search engine to do it. That
|
|
653
|
+
// rule is `embeddedRecoverUrl`'s, in the policy crate.
|
|
654
|
+
const url = (0, summrise_shell_policy_1.embeddedRecoverUrl)(embeddedUrl);
|
|
661
655
|
view.webContents.loadURL(url).catch(() => { });
|
|
662
656
|
// Re-show if the SPA slot is live (bounds were placed before the crash).
|
|
663
657
|
if (embeddedVisible && win && embeddedBounds) {
|
|
@@ -678,7 +672,10 @@ ipcHandle("embedded-browser:zoom", (factor) => {
|
|
|
678
672
|
const view = embeddedView;
|
|
679
673
|
if (!view || view.webContents.isDestroyed())
|
|
680
674
|
return { ok: false };
|
|
681
|
-
|
|
675
|
+
// The clamp AND the `Number(factor) || 1` falsy test are the policy crate's, because `zoomFactor(0)`
|
|
676
|
+
// is 100% and not 50% — a boundary that a hand-written `Math.min/Math.max` here got right by
|
|
677
|
+
// accident and a rewrite would not.
|
|
678
|
+
const f = (0, summrise_shell_policy_1.zoomFactor)(factor);
|
|
682
679
|
try {
|
|
683
680
|
view.webContents.setZoomFactor(f);
|
|
684
681
|
return { ok: true, factor: f };
|
|
@@ -707,14 +704,50 @@ ipcHandle("embedded-browser:state", () => embeddedState());
|
|
|
707
704
|
let dshView = null;
|
|
708
705
|
let dshVisible = false;
|
|
709
706
|
let dshBounds = null;
|
|
710
|
-
/** The DSH's port. Env first (a deployment may move it), else the component's default
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
return
|
|
707
|
+
/** The DSH's port. Env first (a deployment may move it), else the component's default — the range test
|
|
708
|
+
* and the fallback are the policy crate's, the env read is this file's. */
|
|
709
|
+
function bootDshPort() {
|
|
710
|
+
return (0, summrise_shell_policy_1.resolveDshPort)(process.env.SUMMRISE_DSH_PORT);
|
|
714
711
|
}
|
|
715
|
-
/** The ONE load door for this view: its own origin, or nothing.
|
|
716
|
-
|
|
717
|
-
|
|
712
|
+
/** The ONE load door for this view: its own origin, or nothing.
|
|
713
|
+
*
|
|
714
|
+
* TWO OWNERS, ONE ANSWER: `isDshUrl` is the URL policy's predicate over its own port list, and this
|
|
715
|
+
* file evaluates it because that list is state the two wasm modules must not each hold a copy of (see
|
|
716
|
+
* the import block). The SERIALIZATION of an admitted target — and the refusal of everything else — is
|
|
717
|
+
* the shell policy's. */
|
|
718
|
+
function dshTargetFor(raw) {
|
|
719
|
+
return (0, summrise_shell_policy_1.dshTarget)(raw, (0, summrise_url_policy_1.isDshUrl)(raw));
|
|
720
|
+
}
|
|
721
|
+
// ── ONE DOOR PER HOST, TAKEN FROM THE AGENT'S OWN TABLE ───────────────────────────────────────────────
|
|
722
|
+
// The harness page shows the SELECTED host's own harness, and each host is reached through its own
|
|
723
|
+
// forward on this machine's loopback. Those ports are not a shell setting: the agent keeps them, because
|
|
724
|
+
// it is what knows which host a forward belongs to. So the shell asks, once at boot, and admits what the
|
|
725
|
+
// answer names — never a port of its own choosing, and never a host that is not in the table.
|
|
726
|
+
//
|
|
727
|
+
// A read that fails leaves the list as it was. A shell that widened its own door list on a failed read is
|
|
728
|
+
// exactly how the view ends up pointed somewhere it was not told about.
|
|
729
|
+
function loadHarnessDoors() {
|
|
730
|
+
// `agentBase()` rather than a second port read: `setAgentPort` has already run at this point, so the
|
|
731
|
+
// URL policy's base IS what `resolveAgentPort()` would compute — one answer, not two.
|
|
732
|
+
const base = (0, summrise_url_policy_1.agentBase)();
|
|
733
|
+
const controller = new AbortController();
|
|
734
|
+
const timer = setTimeout(() => controller.abort(), HARNESS_FETCH_MS);
|
|
735
|
+
fetch(`${base}/api/workspace/harnesses`, { headers: authHeaders(), signal: controller.signal })
|
|
736
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
737
|
+
.then((answer) => {
|
|
738
|
+
const rows = answer?.harnesses;
|
|
739
|
+
// THREE OWNERS, ONE DOOR LIST: the agent NAMES the ports (`local_port` per host), the policy crate
|
|
740
|
+
// says which are usable (`Array.isArray` and `Number(row?.local_port)` are JavaScript operations on
|
|
741
|
+
// a JavaScript value, so they happen inside `harnessDoors`; the RANGE TEST is the same one every
|
|
742
|
+
// other port in the shell goes through), and the URL policy ADMITS them — `addDshPort` dedupes
|
|
743
|
+
// against its own list, which is state this module must not keep a second copy of.
|
|
744
|
+
for (const port of (0, summrise_shell_policy_1.harnessDoors)(rows))
|
|
745
|
+
(0, summrise_url_policy_1.addDshPort)(port);
|
|
746
|
+
})
|
|
747
|
+
.catch(() => {
|
|
748
|
+
/* the list stays as it was: one host, or none */
|
|
749
|
+
})
|
|
750
|
+
.finally(() => clearTimeout(timer));
|
|
718
751
|
}
|
|
719
752
|
function dshViewEnsure() {
|
|
720
753
|
if (dshView && !dshView.webContents.isDestroyed())
|
|
@@ -735,9 +768,9 @@ function dshViewEnsure() {
|
|
|
735
768
|
// practice an external link is simply dropped rather than silently navigating this view away
|
|
736
769
|
// from the harness.
|
|
737
770
|
view.webContents.setWindowOpenHandler(({ url }) => {
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
740
|
-
dshNavigate(
|
|
771
|
+
const decided = (0, summrise_shell_policy_1.dshPopupTarget)(url, (0, summrise_url_policy_1.isDshUrl)(url));
|
|
772
|
+
if (decided !== undefined)
|
|
773
|
+
dshNavigate(decided);
|
|
741
774
|
return { action: "deny" };
|
|
742
775
|
});
|
|
743
776
|
view.webContents.on("render-process-gone", (_e, details) => {
|
|
@@ -759,21 +792,26 @@ function dshViewEnsure() {
|
|
|
759
792
|
return view;
|
|
760
793
|
}
|
|
761
794
|
function dshNavigate(url) {
|
|
762
|
-
const target =
|
|
795
|
+
const target = dshTargetFor(url);
|
|
763
796
|
const view = dshViewEnsure();
|
|
764
797
|
view.webContents.loadURL(target).catch(() => { });
|
|
765
798
|
return { ok: true, url: target };
|
|
766
799
|
}
|
|
767
|
-
/** Open (or re-focus) the DSH at its configured address. Idempotent.
|
|
800
|
+
/** Open (or re-focus) the DSH at its configured address. Idempotent.
|
|
801
|
+
*
|
|
802
|
+
* `dshHome` is the policy crate's and it was spelled TWICE in this file before the port — here and in
|
|
803
|
+
* `dshRecover` — which is exactly the pair that must not drift: a crash recovery that lands somewhere
|
|
804
|
+
* other than the harness is a view the operator did not ask for. */
|
|
768
805
|
function dshOpen() {
|
|
769
|
-
return dshNavigate((0,
|
|
806
|
+
return dshNavigate((0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)()));
|
|
770
807
|
}
|
|
771
808
|
/** Place the view over the SPA's slot. Empty bounds hide it (the same contract as the browser). */
|
|
772
809
|
function dshPlace(bounds) {
|
|
773
810
|
const view = dshViewEnsure();
|
|
774
811
|
if (!win)
|
|
775
812
|
return;
|
|
776
|
-
|
|
813
|
+
// The SAME predicate the browser view uses — one `MIN_SLOT_PX`, two call sites, before the port.
|
|
814
|
+
if ((0, summrise_shell_policy_1.slotTooSmall)(bounds)) {
|
|
777
815
|
view.setVisible(false);
|
|
778
816
|
dshVisible = false;
|
|
779
817
|
return;
|
|
@@ -813,7 +851,7 @@ function dshRecover() {
|
|
|
813
851
|
const view = dshViewEnsure();
|
|
814
852
|
if (!view || view.webContents.isDestroyed())
|
|
815
853
|
return { ok: false };
|
|
816
|
-
view.webContents.loadURL((0,
|
|
854
|
+
view.webContents.loadURL((0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)())).catch(() => { });
|
|
817
855
|
if (dshVisible && win && dshBounds) {
|
|
818
856
|
view.setBounds(dshBounds);
|
|
819
857
|
view.setVisible(true);
|
|
@@ -832,6 +870,10 @@ ipcHandle("embedded-dsh:place", (bounds) => {
|
|
|
832
870
|
return { ok: true };
|
|
833
871
|
});
|
|
834
872
|
ipcHandle("embedded-dsh:state", () => dshState());
|
|
873
|
+
// SELECT A HOST: `dshNavigate` already refuses anything `isDshUrl` does not admit, so this channel can
|
|
874
|
+
// only reach a door the agent configured — and it answers with the address it actually loaded, which for a
|
|
875
|
+
// refusal is `about:blank`. The pane shows that rather than pretending the switch worked.
|
|
876
|
+
ipcHandle("embedded-dsh:go", (url) => dshNavigate(String(url)));
|
|
835
877
|
ipcHandle("embedded-dsh:reload", () => dshReload());
|
|
836
878
|
ipcHandle("embedded-dsh:recover", () => dshRecover());
|
|
837
879
|
// Desktop-app settings (auto-launch) — the Settings page toggles this. We
|
|
@@ -839,7 +881,7 @@ ipcHandle("embedded-dsh:recover", () => dshRecover());
|
|
|
839
881
|
// Electron's setLoginItemSettings: in dev mode (electron .) the login-item
|
|
840
882
|
// API is unreliable, while schtasks works for the current user without
|
|
841
883
|
// elevation. The task runs start-desktop.ps1 (non-elevated → clickable).
|
|
842
|
-
const AUTOSTART_TASK =
|
|
884
|
+
const AUTOSTART_TASK = K.AUTOSTART_TASK;
|
|
843
885
|
// Resolve the autostart script from the install root (layout v2: the shell
|
|
844
886
|
// lives at <install>\components\summrise-desktop-electron\src\, so the script
|
|
845
887
|
// is at <install>\scripts\ — process.cwd() depends on how the shell was
|
|
@@ -849,7 +891,7 @@ const AUTOSTART_SCRIPT = path.join(INSTALL_ROOT, "scripts", "start-desktop.ps1")
|
|
|
849
891
|
async function autoLaunchTaskExists() {
|
|
850
892
|
// review #4: was sync execSync schtasks — the same hang class that killed
|
|
851
893
|
// electron; route through the bounded async runner.
|
|
852
|
-
const r = await runSchtasks(
|
|
894
|
+
const r = await runSchtasks((0, summrise_shell_policy_1.schtasksQueryArgs)(AUTOSTART_TASK));
|
|
853
895
|
return r.ok;
|
|
854
896
|
}
|
|
855
897
|
/** Run schtasks asynchronously with a hard timeout — the SYNC execSync
|
|
@@ -869,7 +911,7 @@ function runSchtasks(args) {
|
|
|
869
911
|
const t = setTimeout(() => { try {
|
|
870
912
|
child.kill();
|
|
871
913
|
}
|
|
872
|
-
catch { } resolve({ ok: false, error: "schtasks timed out" }); },
|
|
914
|
+
catch { } resolve({ ok: false, error: "schtasks timed out" }); }, SCHTASKS_TIMEOUT_MS);
|
|
873
915
|
child.on("error", (e) => { clearTimeout(t); resolve({ ok: false, error: String(e) }); });
|
|
874
916
|
child.on("close", (code) => { clearTimeout(t); resolve({ ok: code === 0, error: code === 0 ? undefined : `schtasks exit ${code}` }); });
|
|
875
917
|
}
|
|
@@ -880,45 +922,52 @@ function runSchtasks(args) {
|
|
|
880
922
|
}
|
|
881
923
|
async function autoLaunchTaskSet(enabled) {
|
|
882
924
|
try {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
925
|
+
// THE PLAN IS THE POLICY CRATE'S (`autoLaunchPlan(enabled, exists)`): create when it is asked for and
|
|
926
|
+
// absent, end-then-delete when it is refused and present, and NOTHING when it is already in the
|
|
927
|
+
// state asked for — which is a SUCCESS and not a no-op to be apologised for. This file asks whether
|
|
928
|
+
// the task exists and runs the argv; it decides neither.
|
|
929
|
+
switch ((0, summrise_shell_policy_1.autoLaunchPlan)(!!enabled, await autoLaunchTaskExists())) {
|
|
930
|
+
case "create": {
|
|
931
|
+
// Under SYSTEM, a spawn-array schtasks /create without /ru fails with
|
|
932
|
+
// "no mapping between account names and security IDs" (exit 1) — the
|
|
933
|
+
// interactive user is not resolvable from the service session. A bare
|
|
934
|
+
// STRING invocation happens to work (shell default), but spawn arrays
|
|
935
|
+
// need an explicit account: use Administrator (the d1 console user).
|
|
936
|
+
//
|
|
937
|
+
// THIS PATH CANNOT REPRODUCE WHAT `summrise` REGISTERS, and the difference matters. The CLI
|
|
938
|
+
// gives SummriseDesktop TWO triggers — AtLogOn plus a guarded 5-minute pulse (bin/summrise.js:
|
|
939
|
+
// `New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -RepetitionInterval 5min`, run
|
|
940
|
+
// through desktop-pulse.vbs → ensure-desktop.ps1, which exits when electron is already up).
|
|
941
|
+
// `schtasks /create` has no way to express a repetition interval at all, so what this writes is
|
|
942
|
+
// a LOGON-ONLY task: toggling auto-launch OFF deletes the hardened task, and toggling it ON
|
|
943
|
+
// again replaces it with one that cannot recover a wedged shell. `summrise update` heals it and
|
|
944
|
+
// says so in its log — "desk: SummriseDesktop hardened (guarded 5-min pulse)" — which is how
|
|
945
|
+
// this collision was found in the first place.
|
|
946
|
+
//
|
|
947
|
+
// Doing it properly means New-ScheduledTaskTrigger through PowerShell (the CLI's shape,
|
|
948
|
+
// duplicated in JS) or delegating to the CLI; neither is a one-liner, and the update-time repair
|
|
949
|
+
// bounds the damage to "until the next update". Recorded where the downgrade happens, so the
|
|
950
|
+
// next reader sees the trap instead of rediscovering it from a shell that stopped recovering.
|
|
951
|
+
// `schtasksCreateArgs` carries the quoting trap the crate documents: `schtasks` re-parses the
|
|
952
|
+
// /tr VALUE as a command line, so the script path needs its OWN inner quotes while the spawn
|
|
953
|
+
// array keeps the outer argument intact. It is also where the `/ru Administrator` and the
|
|
954
|
+
// logon-only SHAPE are recorded — the toggle cannot reproduce what `summrise` registers, and the
|
|
955
|
+
// crate says so where the downgrade happens.
|
|
956
|
+
const r = await runSchtasks((0, summrise_shell_policy_1.schtasksCreateArgs)(AUTOSTART_TASK, AUTOSTART_SCRIPT));
|
|
957
|
+
if (!r.ok)
|
|
958
|
+
return r;
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
case "remove": {
|
|
962
|
+
// End the task first (a RUNNING task's process tree is terminated on delete) then remove it —
|
|
963
|
+
// the current electron instance must survive.
|
|
964
|
+
await runSchtasks((0, summrise_shell_policy_1.schtasksEndArgs)(AUTOSTART_TASK));
|
|
965
|
+
const r = await runSchtasks((0, summrise_shell_policy_1.schtasksDeleteArgs)(AUTOSTART_TASK));
|
|
966
|
+
if (!r.ok)
|
|
967
|
+
return r;
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
default: break; // "nothing" — already in the state that was asked for
|
|
922
971
|
}
|
|
923
972
|
return { ok: true, enabled };
|
|
924
973
|
}
|
|
@@ -936,21 +985,21 @@ ipcHandle("desktop:set-auto-launch", async (enabled) => autoLaunchTaskSet(!!enab
|
|
|
936
985
|
// connect succeeding, so the watchdog's miss counter NEVER reached the gate,
|
|
937
986
|
// the wait page never showed, and a stuck device stayed stuck in silence.
|
|
938
987
|
// Liveness = GET /api/status answers 200 within the budget.
|
|
939
|
-
function agentResponds(timeoutMs =
|
|
988
|
+
function agentResponds(timeoutMs = AGENT_PROBE_DEFAULT_MS) {
|
|
940
989
|
return new Promise((res) => {
|
|
941
990
|
let done = false;
|
|
942
991
|
const finish = (v) => { if (!done) {
|
|
943
992
|
done = true;
|
|
944
993
|
res(v);
|
|
945
994
|
} };
|
|
946
|
-
const req = http.get(`${(0,
|
|
995
|
+
const req = http.get(`${(0, summrise_url_policy_1.agentBase)()}/api/status`, { timeout: timeoutMs }, (r) => {
|
|
947
996
|
r.resume();
|
|
948
997
|
// ANY HTTP response = the accept loop is alive (this is the exact thing
|
|
949
998
|
// the TCP probe could not see). Do NOT require 200: /api/status is
|
|
950
999
|
// token-gated, so a HEALTHY agent answers 401 to the shell's
|
|
951
1000
|
// credential-less probe — requiring 200 made a live agent look dead and
|
|
952
1001
|
// sent the watchdog into a restart loop (self-caught regression).
|
|
953
|
-
finish(
|
|
1002
|
+
finish((0, summrise_shell_policy_1.statusIsAlive)(r.statusCode));
|
|
954
1003
|
});
|
|
955
1004
|
req.on("timeout", () => { req.destroy(); finish(false); });
|
|
956
1005
|
req.on("error", () => finish(false));
|
|
@@ -964,7 +1013,7 @@ async function startAgentTask() {
|
|
|
964
1013
|
// review #1 (HIGH): sync execSync on the wait-page button / IPC / HTTP
|
|
965
1014
|
// paths — one wedged schtasks froze the WHOLE main process (the exact
|
|
966
1015
|
// hang class already fixed for the watchdog). Same bounded async runner.
|
|
967
|
-
return runSchtasks(
|
|
1016
|
+
return runSchtasks((0, summrise_shell_policy_1.schtasksRunArgs)(AGENT_TASK));
|
|
968
1017
|
}
|
|
969
1018
|
// The SPA asks the shell for agent status / to (re)start the agent task.
|
|
970
1019
|
// IPC audit #6: shell:agent-status / shell:start-agent had ZERO consumers
|
|
@@ -972,7 +1021,12 @@ async function startAgentTask() {
|
|
|
972
1021
|
// Dead handlers removed — every exposed channel is attack surface.
|
|
973
1022
|
// Fallback HTTP path (plain browser / no preload): same core, CORS-open.
|
|
974
1023
|
const httpServer = http.createServer((req, res) => {
|
|
975
|
-
|
|
1024
|
+
// THE REQUEST PARSE AND THE ROUTER ARE THE POLICY CRATE'S. `controlPath` resolves the request target
|
|
1025
|
+
// against the loopback base the way `new URL(req.url, "http://127.0.0.1")` did, and — the ONE named
|
|
1026
|
+
// divergence in that crate — a malformed request target answers `undefined` instead of throwing out of
|
|
1027
|
+
// this listener, which in the original took the whole Electron main process with it. `undefined` falls
|
|
1028
|
+
// through to the 404 below.
|
|
1029
|
+
const pathname = (0, summrise_shell_policy_1.controlPath)(req.url) ?? "";
|
|
976
1030
|
const send = (obj, code = 200) => {
|
|
977
1031
|
// review #8: was ACAO:* with no origin check — ANY web page open in ANY
|
|
978
1032
|
// local browser could POST /api/shell/start-agent or spam
|
|
@@ -981,14 +1035,18 @@ const httpServer = http.createServer((req, res) => {
|
|
|
981
1035
|
// origins (reads included — the GET routes expose session URLs and
|
|
982
1036
|
// device facts). "null" (our data: wait page) and absent
|
|
983
1037
|
// (native tooling/curl) stay allowed.
|
|
984
|
-
res.setHeader("access-control-allow-origin", req.headers.origin
|
|
985
|
-
res.setHeader("access-control-allow-methods",
|
|
986
|
-
res.setHeader("access-control-allow-headers",
|
|
987
|
-
res.setHeader("vary",
|
|
1038
|
+
res.setHeader("access-control-allow-origin", (0, summrise_shell_policy_1.corsAllowOrigin)(req.headers.origin));
|
|
1039
|
+
res.setHeader("access-control-allow-methods", K.CORS_ALLOW_METHODS);
|
|
1040
|
+
res.setHeader("access-control-allow-headers", K.CORS_ALLOW_HEADERS);
|
|
1041
|
+
res.setHeader("vary", K.CORS_VARY);
|
|
988
1042
|
res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" });
|
|
989
1043
|
res.end(JSON.stringify(obj));
|
|
990
1044
|
};
|
|
991
|
-
|
|
1045
|
+
// A PREFLIGHT IS ANSWERED BEFORE THE VETO, and that ordering is the policy crate's
|
|
1046
|
+
// (`controlIsPreflight`) rather than a detail of this function: moving the check below the veto would
|
|
1047
|
+
// start 403-ing legitimate CORS preflights and the plain-browser path would stop working with no other
|
|
1048
|
+
// symptom.
|
|
1049
|
+
if ((0, summrise_shell_policy_1.controlIsPreflight)(req.method ?? ""))
|
|
992
1050
|
return send({ ok: true });
|
|
993
1051
|
// Foreign-origin veto applies to READS too: the GET routes
|
|
994
1052
|
// (/api/browser-session/list, /api/shell/agent-status,
|
|
@@ -1000,34 +1058,30 @@ const httpServer = http.createServer((req, res) => {
|
|
|
1000
1058
|
// this set — no live extension caller exists (the extension only talks
|
|
1001
1059
|
// to its configured gateway origin, never to 127.0.0.1:9444), so the
|
|
1002
1060
|
// veto stays as-is; an extension caller needs an explicit entry here.
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
// the predicate finally has a test, because the shell's suite never ran.
|
|
1009
|
-
if (!(0, url_policy_1.controlOriginOk)(req.headers.origin)) {
|
|
1010
|
-
return send({ ok: false, error: "forbidden origin" }, 403);
|
|
1011
|
-
}
|
|
1061
|
+
// THE DECISION ITSELF IS RUST (agent/summrise-url-policy) and was an inline regex with NO `$` ANCHOR,
|
|
1062
|
+
// so `http://127.0.0.1.evil.com` passed — the same class as the `startsWith(BASE)` bug that crate
|
|
1063
|
+
// exists to kill.
|
|
1064
|
+
if (!(0, summrise_url_policy_1.controlOriginOk)(req.headers.origin)) {
|
|
1065
|
+
return send({ ok: false, error: K.FORBIDDEN_ORIGIN }, 403);
|
|
1012
1066
|
}
|
|
1013
1067
|
try {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
return send(
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
return (async () => send({ ok: true, running: await agentResponds(
|
|
1024
|
-
|
|
1025
|
-
return send({ ok: true, icons: iconReport,
|
|
1068
|
+
// THE ROUTE TABLE IS THE POLICY CRATE'S, INCLUDING ITS TWO ASYMMETRIES, which are the original
|
|
1069
|
+
// behaviour and not accidents to tidy: `/list`, `/agent-status` and `/icon-status` have NO method
|
|
1070
|
+
// guard (a POST reaches the same reader), while `/open`, `/close` and `/start-agent` require POST and
|
|
1071
|
+
// a GET to one of them falls through to the 404.
|
|
1072
|
+
switch ((0, summrise_shell_policy_1.controlRoute)(req.method ?? "", pathname)) {
|
|
1073
|
+
case "browser-open": return send(browserOpen((0, summrise_shell_policy_1.controlQuery)(req.url, "url") || K.ABOUT_BLANK));
|
|
1074
|
+
case "browser-close": return send(browserClose((0, summrise_shell_policy_1.controlQuery)(req.url, "id") || ""));
|
|
1075
|
+
case "browser-list": return send(browserList());
|
|
1076
|
+
case "start-agent": return (async () => send(await startAgentTask()))();
|
|
1077
|
+
case "agent-status": return (async () => send({ ok: true, running: await agentResponds(CONTROL_PROBE_MS) }))();
|
|
1078
|
+
case "icon-status": return send({ ok: true, icons: iconReport,
|
|
1026
1079
|
native: {
|
|
1027
1080
|
tray: probeImage(String(iconReport["tray"]?.path || "")),
|
|
1028
1081
|
window: probeImage(String(iconReport["window"]?.path || "")),
|
|
1029
1082
|
} });
|
|
1030
|
-
|
|
1083
|
+
default: send({ ok: false, error: K.NOT_FOUND }, 404);
|
|
1084
|
+
}
|
|
1031
1085
|
}
|
|
1032
1086
|
catch (e) {
|
|
1033
1087
|
send({ ok: false, error: String(e) }, 500);
|
|
@@ -1061,22 +1115,27 @@ if (gotTheLock) {
|
|
|
1061
1115
|
// (The same shortcut association is also the documented precondition for the hide-to-tray
|
|
1062
1116
|
// toast below: Electron requires a Start Menu shortcut carrying the ID and a
|
|
1063
1117
|
// ToastActivatorCLSID, and this one carries neither — unchanged by this call.)
|
|
1064
|
-
|
|
1118
|
+
// THE VALUE, THE PLATFORM TEST AND THE TWO REPORT STRINGS ARE THE POLICY CRATE'S. It is a COPY of the
|
|
1119
|
+
// CLI's `DESKTOP_AUMID` and the two must stay in step — `agent/tests/shared_literals.rs` now compares
|
|
1120
|
+
// them from both sources and refuses a disagreement, which is what "KEEP THE TWO IN STEP" needed to
|
|
1121
|
+
// stop being a comment. `app.setAppUserModelId` is this file's.
|
|
1065
1122
|
try {
|
|
1066
|
-
if (process.platform
|
|
1067
|
-
electron_1.app.setAppUserModelId(
|
|
1068
|
-
iconReport["appUserModelId"] = process.platform
|
|
1123
|
+
if ((0, summrise_shell_policy_1.usesAppUserModelId)(process.platform))
|
|
1124
|
+
electron_1.app.setAppUserModelId(K.DESKTOP_AUMID);
|
|
1125
|
+
iconReport["appUserModelId"] = (0, summrise_shell_policy_1.aumidReport)(process.platform);
|
|
1069
1126
|
}
|
|
1070
1127
|
catch {
|
|
1071
|
-
iconReport["appUserModelId"] =
|
|
1128
|
+
iconReport["appUserModelId"] = K.AUMID_SET_FAILED;
|
|
1072
1129
|
}
|
|
1073
1130
|
electron_1.app.whenReady().then(async () => {
|
|
1074
1131
|
// Custom-port installs: pin every origin predicate + probe/load URL to
|
|
1075
1132
|
// the agent's actual bind port BEFORE any window or probe exists.
|
|
1076
|
-
(0,
|
|
1133
|
+
(0, summrise_url_policy_1.setAgentPort)(bootAgentPort());
|
|
1077
1134
|
// The DSH view's door, pinned the same way and for the same reason: a predicate that ran
|
|
1078
1135
|
// before this line would check a port nothing is listening on.
|
|
1079
|
-
(0,
|
|
1136
|
+
(0, summrise_url_policy_1.setDshPort)(bootDshPort());
|
|
1137
|
+
// Every host's forward is a door, admitted from the agent's table rather than from configuration here.
|
|
1138
|
+
loadHarnessDoors();
|
|
1080
1139
|
// review #7: with no handler Electron AUTO-GRANTS every permission
|
|
1081
1140
|
// request (media/geolocation/clipboard) — deny by default for all
|
|
1082
1141
|
// windows, esp. the remote-browser ones loading arbitrary pages.
|
|
@@ -1085,8 +1144,8 @@ if (gotTheLock) {
|
|
|
1085
1144
|
electron_1.session.defaultSession.setPermissionCheckHandler(() => false);
|
|
1086
1145
|
}
|
|
1087
1146
|
catch { /* non-fatal */ }
|
|
1088
|
-
httpServer.listen(CTRL_PORT, "127.0.0.1");
|
|
1089
|
-
console.log(`[summrise] browser-session control: http://127.0.0.1:${CTRL_PORT}`);
|
|
1147
|
+
httpServer.listen(K.CTRL_PORT, "127.0.0.1");
|
|
1148
|
+
console.log(`[summrise] browser-session control: http://127.0.0.1:${K.CTRL_PORT}`);
|
|
1090
1149
|
// Native application menu (stage-l): menu commands → SPA via summrise-menu.
|
|
1091
1150
|
electron_1.Menu.setApplicationMenu(buildMenu());
|
|
1092
1151
|
win = new electron_1.BrowserWindow({
|
|
@@ -1138,7 +1197,7 @@ if (gotTheLock) {
|
|
|
1138
1197
|
// audit #1: PARSED-origin veto. The data: carve-out was unnecessary
|
|
1139
1198
|
// (programmatic loadURL — including the wait page — never fires
|
|
1140
1199
|
// will-navigate) and only widened the hole.
|
|
1141
|
-
if (!(0,
|
|
1200
|
+
if (!(0, summrise_url_policy_1.isBaseOrigin)(url))
|
|
1142
1201
|
e.preventDefault();
|
|
1143
1202
|
});
|
|
1144
1203
|
// round-258 (device-caught): CDP-driven navigation (an attached
|
|
@@ -1152,17 +1211,17 @@ if (gotTheLock) {
|
|
|
1152
1211
|
win.webContents.on("did-navigate", (_e, url) => {
|
|
1153
1212
|
if (snappingBack)
|
|
1154
1213
|
return;
|
|
1155
|
-
// Parsed-origin + parsed-pathname allow-list
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
if (url
|
|
1214
|
+
// Parsed-origin + parsed-pathname allow-list: the string startsWith was the exact class IPC audit
|
|
1215
|
+
// #1 flagged, so `isDesktopSpaUrl` (the URL policy's) supplies that half and the policy crate
|
|
1216
|
+
// composes it with the two literal carve-outs — the data: wait page and about:blank.
|
|
1217
|
+
//
|
|
1218
|
+
// The port deleted a dead `if (url === "about:blank") return;` here — the line above already returns for it.
|
|
1219
|
+
if ((0, summrise_shell_policy_1.tripwireAllows)(url, (0, summrise_url_policy_1.isDesktopSpaUrl)(url)))
|
|
1161
1220
|
return;
|
|
1162
|
-
console.log(
|
|
1221
|
+
console.log((0, summrise_shell_policy_1.tripwireLog)(url));
|
|
1163
1222
|
snappingBack = true;
|
|
1164
|
-
win?.loadURL(`${(0,
|
|
1165
|
-
setTimeout(() => { snappingBack = false; },
|
|
1223
|
+
win?.loadURL(`${(0, summrise_url_policy_1.agentBase)()}/desktop/`).catch(() => { }).finally(() => {
|
|
1224
|
+
setTimeout(() => { snappingBack = false; }, TRIPWIRE_BACKOFF_MS);
|
|
1166
1225
|
});
|
|
1167
1226
|
});
|
|
1168
1227
|
// review #7: a target=_blank from the SPA must NOT get a preload-bearing
|
|
@@ -1183,7 +1242,7 @@ if (gotTheLock) {
|
|
|
1183
1242
|
// agentReady() stayed true, so the miss counter below NEVER incremented
|
|
1184
1243
|
// and the watchdog could not fire (the device stayed stuck in silence,
|
|
1185
1244
|
// the exact failure the watchdog exists to prevent).
|
|
1186
|
-
const agentReady = async () => agentResponds(
|
|
1245
|
+
const agentReady = async () => agentResponds(AGENT_PROBE_BOOT_MS);
|
|
1187
1246
|
// stage-n: the wait page carries a "Start Agent" action — the header
|
|
1188
1247
|
// comment promised it but it never existed. The button calls the shell's
|
|
1189
1248
|
// own /api/shell/start-agent (schtasks /run SummriseAgent — the only
|
|
@@ -1246,12 +1305,12 @@ if (gotTheLock) {
|
|
|
1246
1305
|
</style>
|
|
1247
1306
|
<div>
|
|
1248
1307
|
<h2>The Summrise Agent isn't answering</h2>
|
|
1249
|
-
<p id="status">no reply from ${(0,
|
|
1308
|
+
<p id="status">no reply from ${(0, summrise_shell_policy_1.agentHostLabel)((0, summrise_url_policy_1.agentBase)())}</p>
|
|
1250
1309
|
<p id="action" hidden></p>
|
|
1251
1310
|
<button id="start">Start Agent</button>
|
|
1252
1311
|
</div>
|
|
1253
1312
|
<script>
|
|
1254
|
-
var BASE = ${JSON.stringify((0,
|
|
1313
|
+
var BASE = ${JSON.stringify((0, summrise_shell_policy_1.agentHostLabel)((0, summrise_url_policy_1.agentBase)()))};
|
|
1255
1314
|
var CTRL = "http://127.0.0.1:9444";
|
|
1256
1315
|
var btn = document.getElementById("start");
|
|
1257
1316
|
var st = document.getElementById("status");
|
|
@@ -1305,13 +1364,17 @@ if (gotTheLock) {
|
|
|
1305
1364
|
// DO NOT RE-LOAD A PAGE THAT IS ALREADY SHOWING (see the note above): the
|
|
1306
1365
|
// reload wiped the button's answer and reset the evidence line, which is
|
|
1307
1366
|
// what made the only control on this screen unfalsifiable.
|
|
1308
|
-
|
|
1367
|
+
// `isWaitPage` is the policy crate's, and the prefix test is SAFE because the URL it tests is one
|
|
1368
|
+
// this file built — no page can make itself match.
|
|
1369
|
+
if ((0, summrise_shell_policy_1.isWaitPage)(win?.webContents.getURL() ?? ""))
|
|
1309
1370
|
return;
|
|
1310
1371
|
win?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(waitPageHtml(checks))}`).catch(() => { });
|
|
1311
1372
|
};
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1373
|
+
// THE BACKOFF IS THE POLICY CRATE'S: double, capped at `RETRY_CAP_MS`, and reset to `RETRY_START_MS`
|
|
1374
|
+
// on a successful probe.
|
|
1375
|
+
let retryMs = RETRY_START_MS;
|
|
1376
|
+
const nextRetry = () => { retryMs = (0, summrise_shell_policy_1.nextRetryMs)(retryMs); return retryMs; };
|
|
1377
|
+
const resetRetry = () => { retryMs = RETRY_START_MS; };
|
|
1315
1378
|
// NO setVersionTitle() HERE, DELETED. It fetched /api/status with the device token and
|
|
1316
1379
|
// called win.setTitle("Summrise — v1.2.x"), and that title NEVER REACHED THE SCREEN: the
|
|
1317
1380
|
// SPA owns document.title (`useAttentionTitle` assigns it on every attention change,
|
|
@@ -1333,13 +1396,11 @@ if (gotTheLock) {
|
|
|
1333
1396
|
// free — the same hang class that killed the old auto-launch toggle.
|
|
1334
1397
|
let agentMissCount = 0;
|
|
1335
1398
|
let lastAutoStartAt = 0;
|
|
1336
|
-
const AUTO_START_AFTER_MISSES = 5;
|
|
1337
|
-
const AUTO_START_MIN_GAP_MS = 5 * 60 * 1000;
|
|
1338
1399
|
const loadDesktop = async () => {
|
|
1339
1400
|
if (await agentReady()) {
|
|
1340
1401
|
agentMissCount = 0;
|
|
1341
1402
|
resetRetry();
|
|
1342
|
-
win?.loadURL(`${(0,
|
|
1403
|
+
win?.loadURL(`${(0, summrise_url_policy_1.agentBase)()}/desktop/`).catch(() => { });
|
|
1343
1404
|
}
|
|
1344
1405
|
else {
|
|
1345
1406
|
// THE MISS IS COUNTED BEFORE THE PAGE IS HANDED THE NUMBER, because this
|
|
@@ -1347,11 +1408,14 @@ if (gotTheLock) {
|
|
|
1347
1408
|
// times the shell has asked and got nothing, including this one.
|
|
1348
1409
|
agentMissCount += 1;
|
|
1349
1410
|
loadWaitPage(agentMissCount);
|
|
1350
|
-
|
|
1411
|
+
// THE GATE IS THE POLICY CRATE'S: five consecutive misses AND at least five minutes since the
|
|
1412
|
+
// last self-start. The argv is its too, which is what makes the task name this runs and the name
|
|
1413
|
+
// the CLI registers one fact instead of two.
|
|
1414
|
+
if ((0, summrise_shell_policy_1.watchdogShouldStart)(agentMissCount, lastAutoStartAt, Date.now())) {
|
|
1351
1415
|
lastAutoStartAt = Date.now();
|
|
1352
1416
|
agentMissCount = 0;
|
|
1353
|
-
void runSchtasks(
|
|
1354
|
-
console.log(
|
|
1417
|
+
void runSchtasks((0, summrise_shell_policy_1.schtasksRunArgs)(AGENT_TASK)).then((r) => {
|
|
1418
|
+
console.log((0, summrise_shell_policy_1.watchdogLog)(r.ok, r.error));
|
|
1355
1419
|
});
|
|
1356
1420
|
}
|
|
1357
1421
|
setTimeout(() => { loadDesktop(); }, nextRetry());
|
|
@@ -1364,7 +1428,10 @@ if (gotTheLock) {
|
|
|
1364
1428
|
// ANOTHER loadDesktop chain (accelerated miss counting + duplicate
|
|
1365
1429
|
// loadURLs). Only a real main-frame failure (not ABORTED) should retry.
|
|
1366
1430
|
win.webContents.on("did-fail-load", (_e, errorCode, _desc, _url, isMainFrame) => {
|
|
1367
|
-
|
|
1431
|
+
// `-3` is ERR_ABORTED and it is EXCLUDED on purpose: a same-URL reload dispatches it on the MAIN
|
|
1432
|
+
// frame, and retrying it stacked a second `loadDesktop` chain with accelerated miss counting. The
|
|
1433
|
+
// predicate is the policy crate's.
|
|
1434
|
+
if (!(0, summrise_shell_policy_1.shouldRetryLoad)(!!isMainFrame, errorCode))
|
|
1368
1435
|
return;
|
|
1369
1436
|
setTimeout(() => { loadDesktop(); }, nextRetry());
|
|
1370
1437
|
});
|
|
@@ -1373,7 +1440,7 @@ if (gotTheLock) {
|
|
|
1373
1440
|
win.webContents.on("did-finish-load", () => {
|
|
1374
1441
|
if (menuFlushTimer)
|
|
1375
1442
|
clearTimeout(menuFlushTimer);
|
|
1376
|
-
menuFlushTimer = setTimeout(flushMenuQueue,
|
|
1443
|
+
menuFlushTimer = setTimeout(flushMenuQueue, MENU_FLUSH_MS);
|
|
1377
1444
|
});
|
|
1378
1445
|
await loadDesktop();
|
|
1379
1446
|
// round-259: eagerly create the EMBEDDED view at startup (hidden) so an
|
|
@@ -1390,33 +1457,37 @@ if (gotTheLock) {
|
|
|
1390
1457
|
embeddedViewEnsure();
|
|
1391
1458
|
}
|
|
1392
1459
|
catch { /* non-fatal */ }
|
|
1393
|
-
console.log(`[summrise] CDP endpoint:
|
|
1460
|
+
console.log(`[summrise] CDP endpoint: ${(0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT)} (playwright connectOverCDP)`);
|
|
1394
1461
|
// stage-n: CDP self-check — if 9333 is occupied by another process
|
|
1395
1462
|
// (a second browser/electron), remote-debugging-port silently fails and
|
|
1396
1463
|
// AI driving would hit the WRONG target. Probe /json/version and verify
|
|
1397
1464
|
// the User-Agent belongs to this app.
|
|
1465
|
+
// THE QUESTION IS THE POLICY CRATE'S, THE REQUEST IS THIS FILE'S. It exists because "if 9333 is
|
|
1466
|
+
// occupied by another process (a second browser/electron), `remote-debugging-port` silently fails and
|
|
1467
|
+
// AI driving would hit the WRONG target" — so `/json/version` is probed and the `User-Agent` is
|
|
1468
|
+
// compared against this app's marker. The three messages are the crate's so that the wording a person
|
|
1469
|
+
// reads off a device's log is pinned by a test.
|
|
1398
1470
|
(async () => {
|
|
1399
1471
|
try {
|
|
1400
1472
|
const ctrl = new AbortController();
|
|
1401
|
-
const t = setTimeout(() => ctrl.abort(),
|
|
1402
|
-
const r = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`, { signal: ctrl.signal });
|
|
1473
|
+
const t = setTimeout(() => ctrl.abort(), CDP_CHECK_MS);
|
|
1474
|
+
const r = await fetch(`http://127.0.0.1:${K.CDP_PORT}/json/version`, { signal: ctrl.signal });
|
|
1403
1475
|
clearTimeout(t);
|
|
1404
1476
|
if (r.ok) {
|
|
1405
|
-
const
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
console.log(`[summrise] CDP self-check OK: 127.0.0.1:${CDP_PORT} is this app`);
|
|
1477
|
+
const ua = (0, summrise_shell_policy_1.cdpUserAgent)(await r.text());
|
|
1478
|
+
if ((0, summrise_shell_policy_1.cdpUserAgentOurs)(ua)) {
|
|
1479
|
+
console.log((0, summrise_shell_policy_1.cdpSelfCheckOk)(K.CDP_PORT));
|
|
1409
1480
|
}
|
|
1410
1481
|
else {
|
|
1411
|
-
console.warn(
|
|
1482
|
+
console.warn((0, summrise_shell_policy_1.cdpWarningForeign)(K.CDP_PORT, ua));
|
|
1412
1483
|
}
|
|
1413
1484
|
}
|
|
1414
1485
|
else {
|
|
1415
|
-
console.warn(
|
|
1486
|
+
console.warn((0, summrise_shell_policy_1.cdpWarningNotResponding)(K.CDP_PORT));
|
|
1416
1487
|
}
|
|
1417
1488
|
}
|
|
1418
1489
|
catch {
|
|
1419
|
-
console.warn(
|
|
1490
|
+
console.warn((0, summrise_shell_policy_1.cdpWarningUnreachable)(K.CDP_PORT));
|
|
1420
1491
|
}
|
|
1421
1492
|
})();
|
|
1422
1493
|
win.on("close", (e) => {
|
|
@@ -1446,88 +1517,54 @@ if (gotTheLock) {
|
|
|
1446
1517
|
let trayAgentRunning = false;
|
|
1447
1518
|
// stage-n: guards the single loadDesktop re-entry loop (see tray hook).
|
|
1448
1519
|
let agentWatchActive = false;
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
let
|
|
1455
|
-
let trayAgentMem = null;
|
|
1520
|
+
/** WHAT THE TRAY KNOWS, carried between polls: the facts the last `/api/status` answer left behind.
|
|
1521
|
+
* The RULE — a poll that fails, or an answer that omits a field, KEEPS what the last one said — is
|
|
1522
|
+
* the policy crate's; the STATE is this file's, because a poll is the only thing that observes it.
|
|
1523
|
+
* The two formatters this file used to carry (`fmtUptime`, `fmtClock`) are that crate's too, so
|
|
1524
|
+
* there is no clock face or uptime string built here at all. */
|
|
1525
|
+
let trayFacts = { version: "", uptime: "", sessions: 0, cpu: null, mem: null };
|
|
1456
1526
|
/** WHEN /api/status last ANSWERED (null = it never has since launch) — the tray's
|
|
1457
|
-
* tooltip reports this instead of asserting a state it cannot date.
|
|
1527
|
+
* tooltip reports this instead of asserting a state it cannot date. The policy crate decides what
|
|
1528
|
+
* it MEANS; this file only records it. */
|
|
1458
1529
|
let trayLastAnsweredAt = null;
|
|
1459
|
-
/** "3m 24s" / "1h 5m" / "2d 3h" — compact uptime for the tray. */
|
|
1460
|
-
const fmtUptime = (secs) => {
|
|
1461
|
-
if (secs < 60)
|
|
1462
|
-
return `${secs}s`;
|
|
1463
|
-
if (secs < 3600)
|
|
1464
|
-
return `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
|
1465
|
-
if (secs < 86400)
|
|
1466
|
-
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
|
|
1467
|
-
return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h`;
|
|
1468
|
-
};
|
|
1469
|
-
/** "14:02:31" local — the WHEN of an observation, for the one surface that cannot
|
|
1470
|
-
* re-derive it (a tray tooltip is a string set once per poll, not a render). */
|
|
1471
|
-
const fmtClock = (at) => new Date(at).toTimeString().slice(0, 8);
|
|
1472
1530
|
const refreshTray = async () => {
|
|
1473
1531
|
// HTTP liveness, not the TCP probe (see agentReady above): a wedged-
|
|
1474
1532
|
// but-listening agent must show as STOPPED, not "running".
|
|
1475
|
-
const running = await agentResponds(
|
|
1533
|
+
const running = await agentResponds(AGENT_PROBE_TRAY_MS);
|
|
1476
1534
|
const observedAt = Date.now();
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
let
|
|
1482
|
-
let cpu = trayAgentCpu;
|
|
1483
|
-
let mem = trayAgentMem;
|
|
1535
|
+
// THE PARSE, THE KEEP-LAST RULE, THE VITALS LINE AND THE TOOLTIP'S WORDING ARE ONE CALL INTO THE
|
|
1536
|
+
// POLICY CRATE, and this file hands over the RAW BODY rather than a parsed object: `r.json()`
|
|
1537
|
+
// throwing into a `catch { keep last }` IS the keep-last rule, so the parse belongs where the rule
|
|
1538
|
+
// is. `r.text()` moves the throw across the boundary.
|
|
1539
|
+
let statusText = "";
|
|
1484
1540
|
if (running) {
|
|
1485
1541
|
try {
|
|
1486
1542
|
const ctrl = new AbortController();
|
|
1487
|
-
const t = setTimeout(() => ctrl.abort(),
|
|
1488
|
-
const r = await fetch(`${(0,
|
|
1543
|
+
const t = setTimeout(() => ctrl.abort(), STATUS_FETCH_MS);
|
|
1544
|
+
const r = await fetch(`${(0, summrise_url_policy_1.agentBase)()}/api/status`, { signal: ctrl.signal, headers: authHeaders() });
|
|
1489
1545
|
clearTimeout(t);
|
|
1490
|
-
if (r.ok)
|
|
1491
|
-
|
|
1492
|
-
// npm RELEASE version first (the number that changes per release,
|
|
1493
|
-
// written by update/setup), Cargo protocol `version` as fallback.
|
|
1494
|
-
const v = j.release || j.version;
|
|
1495
|
-
if (v)
|
|
1496
|
-
version = v;
|
|
1497
|
-
if (typeof j.uptime_secs === "number")
|
|
1498
|
-
uptime = fmtUptime(j.uptime_secs);
|
|
1499
|
-
if (typeof j.live_sessions === "number")
|
|
1500
|
-
sessions = j.live_sessions;
|
|
1501
|
-
if (typeof j.cpu_pct === "number")
|
|
1502
|
-
cpu = j.cpu_pct;
|
|
1503
|
-
if (typeof j.mem_pct === "number")
|
|
1504
|
-
mem = j.mem_pct;
|
|
1505
|
-
}
|
|
1546
|
+
if (r.ok)
|
|
1547
|
+
statusText = await r.text();
|
|
1506
1548
|
}
|
|
1507
1549
|
catch { /* keep last */ }
|
|
1508
1550
|
}
|
|
1551
|
+
// THE LOCAL TIME ZONE IS A HOST FACT AND IT IS PASSED PER INSTANT: wasm has no clock and no zone
|
|
1552
|
+
// database, and the two clock faces the tooltip can print are up to a DST boundary apart — one
|
|
1553
|
+
// offset for both would put the wrong hour on one of them for exactly one poll, which is the
|
|
1554
|
+
// "a timestamp does not decay" promise the wording was rewritten to keep.
|
|
1555
|
+
const next = JSON.parse((0, summrise_shell_policy_1.refreshTrayHealth)(JSON.stringify({
|
|
1556
|
+
running,
|
|
1557
|
+
statusText,
|
|
1558
|
+
prev: trayFacts,
|
|
1559
|
+
observedAt,
|
|
1560
|
+
lastAnsweredAt: trayLastAnsweredAt,
|
|
1561
|
+
tzOffsetMin: new Date(observedAt).getTimezoneOffset(),
|
|
1562
|
+
lastTzOffsetMin: new Date(trayLastAnsweredAt ?? observedAt).getTimezoneOffset(),
|
|
1563
|
+
})));
|
|
1564
|
+
trayFacts = next.facts;
|
|
1565
|
+
trayLastAnsweredAt = next.lastAnsweredAt;
|
|
1566
|
+
const health = next.health;
|
|
1509
1567
|
trayAgentRunning = running;
|
|
1510
|
-
trayAgentVersion = version;
|
|
1511
|
-
trayAgentUptime = uptime;
|
|
1512
|
-
trayAgentSessions = sessions;
|
|
1513
|
-
trayAgentCpu = cpu;
|
|
1514
|
-
trayAgentMem = mem;
|
|
1515
|
-
const vitals = running && mem !== null
|
|
1516
|
-
? `, CPU ${cpu === null ? "?" : Math.round(cpu)}% · MEM ${Math.round(mem)}%`
|
|
1517
|
-
: "";
|
|
1518
|
-
// THE TOOLTIP NAMES THE OBSERVATION, NOT A VERDICT (round-275). It used to print
|
|
1519
|
-
// "Agent stopped" as present tense from ONE un-timestamped probe, recomputed every
|
|
1520
|
-
// 30 s — half a minute stale while reading as now, the shape the panel's liveness
|
|
1521
|
-
// strings were already fixed for (`checked 12s ago`, `reachability NOT VERIFIED`).
|
|
1522
|
-
// The WHEN is a CLOCK rather than "answered 12s ago", deliberately: a tooltip is a
|
|
1523
|
-
// string written once per poll and Electron gives it no way to recompute, so a
|
|
1524
|
-
// relative age would freeze at the value it had when written and read as "now" for
|
|
1525
|
-
// the next 30 s — the very failure this line removes. A timestamp does not decay.
|
|
1526
|
-
const health = running
|
|
1527
|
-
? `answered ${fmtClock(observedAt)} · v${version || "?"}${uptime ? `, up ${uptime}` : ""}${sessions ? `, ${sessions} session${sessions === 1 ? "" : "s"}` : ""}${vitals}`
|
|
1528
|
-
: trayLastAnsweredAt === null
|
|
1529
|
-
? "reachability NOT VERIFIED · no reply since launch"
|
|
1530
|
-
: `not answering · last reply ${fmtClock(trayLastAnsweredAt)}`;
|
|
1531
1568
|
// stage-n: the agent died WHILE the window was showing the SPA (the
|
|
1532
1569
|
// wait page only renders when the boot probe fails). Swap the window
|
|
1533
1570
|
// to the wait page — its "Start Agent" button is now actually visible
|
|
@@ -1535,13 +1572,22 @@ if (gotTheLock) {
|
|
|
1535
1572
|
// loadDesktop retry loop, whose watchdog auto-runs `schtasks /run
|
|
1536
1573
|
// SummriseAgent` after ~60 s of misses. One loop guard so repeated tray
|
|
1537
1574
|
// polls cannot stack retries.
|
|
1538
|
-
|
|
1539
|
-
|
|
1575
|
+
//
|
|
1576
|
+
// The CONJUNCTION is the policy crate's; the ORIGIN half is the URL policy's, evaluated only when
|
|
1577
|
+
// the window is live so the original's short-circuit survives (a destroyed window never reaches a
|
|
1578
|
+
// `getURL()`).
|
|
1579
|
+
const liveWindow = win && !win.isDestroyed() ? win : null;
|
|
1580
|
+
if ((0, summrise_shell_policy_1.trayShouldWatch)(running, agentWatchActive, liveWindow !== null, liveWindow ? (0, summrise_url_policy_1.isBaseOrigin)(liveWindow.webContents.getURL()) : false)) {
|
|
1540
1581
|
agentWatchActive = true;
|
|
1541
1582
|
void loadDesktop();
|
|
1542
1583
|
}
|
|
1543
1584
|
if (running && agentWatchActive)
|
|
1544
1585
|
agentWatchActive = false;
|
|
1586
|
+
// THE TOOLTIP NAMES THE OBSERVATION, NOT A VERDICT (round-275), and the crate computes it: it used
|
|
1587
|
+
// to print "Agent stopped" as present tense from ONE un-timestamped probe, recomputed every 30 s —
|
|
1588
|
+
// half a minute stale while reading as now. The WHEN is a CLOCK rather than "answered 12s ago"
|
|
1589
|
+
// because a tooltip is a string written once per poll and Electron gives it no way to recompute; a
|
|
1590
|
+
// relative age would freeze and read as "now" for the next 30 s. A timestamp does not decay.
|
|
1545
1591
|
tray.setToolTip(`Summrise — ${health}`);
|
|
1546
1592
|
// THE FOUR "New …" ROWS ARE GONE (round-275): they duplicated the desktop
|
|
1547
1593
|
// header's `+ New` menu item for item (pty/ssh/serial/browser), one click
|
|
@@ -1556,7 +1602,7 @@ if (gotTheLock) {
|
|
|
1556
1602
|
]));
|
|
1557
1603
|
};
|
|
1558
1604
|
refreshTray();
|
|
1559
|
-
setInterval(() => { refreshTray(); },
|
|
1605
|
+
setInterval(() => { refreshTray(); }, TRAY_POLL_MS);
|
|
1560
1606
|
});
|
|
1561
1607
|
}
|
|
1562
1608
|
electron_1.app.on("before-quit", () => {
|