summrise-agent 1.2.510 → 1.2.512
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 +13 -0
- package/bin/summrise.exe +0 -0
- package/package.json +13 -8
- package/summrise-agent.exe +0 -0
- package/summrise-desktop-electron/src/browser-sessions.js +139 -0
- package/summrise-desktop-electron/src/embedded-slot.js +347 -0
- package/summrise-desktop-electron/src/main.js +461 -736
- 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
package/README.md
CHANGED
|
@@ -22,6 +22,14 @@ npm i -g https://agent.saisi.online/summrise-agent/summrise-agent-latest.tgz
|
|
|
22
22
|
summrise setup --reg-key <key-from-console> # --reg-key is optional (local mode works without it)
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
**ON A DEVICE THE INSTALLER SET UP, A HAND INSTALL MUST PASS `--prefix`.** `agent/deploy/summrise-online-setup.ps1`
|
|
26
|
+
does exactly that — `npm install -g --prefix $NpmGlobal $tgz`, where `$NpmGlobal` is `<install dir>\components\npm-global`
|
|
27
|
+
("Layout v2"; it was `tools\npm-global` before that) — and puts that directory on `PATH`. **A bare `npm i -g` lands in
|
|
28
|
+
npm's DEFAULT global prefix instead**, which is not where the running agent looks, so the install succeeds and nothing
|
|
29
|
+
changes. The layout is the one thing to keep in step, and `summrise update` prints the right prefix for an existing
|
|
30
|
+
device rather than making anyone derive it.
|
|
31
|
+
|
|
32
|
+
|
|
25
33
|
**Run `setup` from an elevated PowerShell.** The install directory is under `Program Files` and the registry key is
|
|
26
34
|
`HKLM`, so a normal shell fails with `EPERM: operation not permitted, mkdir 'C:\Program Files\Summrise\scripts'` —
|
|
27
35
|
a stack trace where the useful sentence is "run it as administrator".
|
|
@@ -47,6 +55,11 @@ npm i -g --prefix (Split-Path (Get-Command summrise).Source) https://agent.saisi
|
|
|
47
55
|
summrise update
|
|
48
56
|
```
|
|
49
57
|
|
|
58
|
+
**YOU DO NOT HAVE TO COMPUTE THAT PREFIX — THE CLI PRINTS IT.** When `summrise update` refuses because the running
|
|
59
|
+
CLI is older than the release channel, it answers with the exact command for THIS device, prefix included, computed from
|
|
60
|
+
where the CLI is running (`Split-Path (Get-Command summrise).Source`, asked of the process that IS that binary). The
|
|
61
|
+
`--prefix` in the block above is what it prints; the paragraph below is why it matters.
|
|
62
|
+
|
|
50
63
|
**The `--prefix` is not optional on a device that is already set up.** Plain
|
|
51
64
|
`npm i -g <url>` installs into npm's DEFAULT global prefix, which is not where
|
|
52
65
|
`summrise` lives when the agent runs as SYSTEM. On a real device the two prefixes
|
package/bin/summrise.exe
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "summrise-agent",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "Summrise Agent
|
|
5
|
-
"//build": "
|
|
3
|
+
"version": "1.2.512",
|
|
4
|
+
"description": "Summrise Agent — Windows device MCP server (terminal/SSH/serial) + panel. Self-hosted npm distribution.",
|
|
5
|
+
"//build": "THE ONLY BUILD PATH: run npm run build in this directory. It is self-contained: it pulls typescript@5 on demand through npm exec, with no preinstalled node_modules (so it runs in a clean checkout; the old script referenced ../summrise-desktop-electron/node_modules/.bin/tsc, which is always ENOENT in a clean checkout). It MUST use the `npm exec --package … -- tsc` form: npx swallows the -p/--outDir arguments passed to tsc (round-435: the npx form never actually ran). LANDING 4b: the CLI IS A RUST BINARY NOW (bin/summrise.exe, built by `cargo xwin build -p summrise-cli` and staged by scripts/build.sh) — nothing here compiles it, and src/summrise.ts + bin/summrise.js are DELETED. This script therefore only compiles the electron TS (--noCheck pure emit, same as release.yml) and syncs both src/*.js copies. LANDING 6: the desktop shell's URL/certificate policy is Rust compiled to wasm. summrise_url_policy.js and summrise_url_policy_bg.wasm are NOT built here — agent/summrise-url-policy/build.sh produces them (wasm-pack) and they are committed — this script only COPIES the committed pair into the package's own summrise-desktop-electron/src/, the way it does for main.js/preload.js, so that npm pack carries what the shell requires.",
|
|
6
6
|
"bin": {
|
|
7
|
-
"summrise": "bin/summrise.
|
|
7
|
+
"summrise": "bin/summrise.exe"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"README.md",
|
|
11
|
-
"bin/",
|
|
11
|
+
"bin/summrise.exe",
|
|
12
12
|
"summrise-agent.exe",
|
|
13
|
+
"summrise-launch.exe",
|
|
13
14
|
"summrise-desktop-electron/src/main.js",
|
|
15
|
+
"summrise-desktop-electron/src/embedded-slot.js",
|
|
16
|
+
"summrise-desktop-electron/src/browser-sessions.js",
|
|
14
17
|
"summrise-desktop-electron/src/preload.js",
|
|
15
|
-
"summrise-desktop-electron/src/
|
|
18
|
+
"summrise-desktop-electron/src/summrise_url_policy.js",
|
|
19
|
+
"summrise-desktop-electron/src/summrise_url_policy_bg.wasm",
|
|
20
|
+
"summrise-desktop-electron/src/summrise_shell_policy.js",
|
|
21
|
+
"summrise-desktop-electron/src/summrise_shell_policy_bg.wasm",
|
|
16
22
|
"summrise-desktop-electron/icon.png",
|
|
17
23
|
"summrise-desktop-electron/icon.ico"
|
|
18
24
|
],
|
|
@@ -24,7 +30,6 @@
|
|
|
24
30
|
],
|
|
25
31
|
"license": "MIT",
|
|
26
32
|
"scripts": {
|
|
27
|
-
"build": "npm exec --yes --package typescript@5.9.3 -- tsc -p ../summrise-desktop-electron/tsconfig.json --outDir /tmp/summrise-electron-fresh --noCheck && cp /tmp/summrise-electron-fresh/main.js ../summrise-desktop-electron/src/main.js && cp /tmp/summrise-electron-fresh/
|
|
28
|
-
"test": "node --test"
|
|
33
|
+
"build": "npm exec --yes --package typescript@5.9.3 -- tsc -p ../summrise-desktop-electron/tsconfig.json --outDir /tmp/summrise-electron-fresh --noCheck && cp /tmp/summrise-electron-fresh/main.js ../summrise-desktop-electron/src/main.js && cp /tmp/summrise-electron-fresh/embedded-slot.js ../summrise-desktop-electron/src/embedded-slot.js && cp /tmp/summrise-electron-fresh/browser-sessions.js ../summrise-desktop-electron/src/browser-sessions.js && cp /tmp/summrise-electron-fresh/preload.js ../summrise-desktop-electron/src/preload.js && cp ../summrise-desktop-electron/src/main.js ../summrise-desktop-electron/src/embedded-slot.js ../summrise-desktop-electron/src/browser-sessions.js ../summrise-desktop-electron/src/preload.js ../summrise-desktop-electron/src/summrise_url_policy.js ../summrise-desktop-electron/src/summrise_url_policy_bg.wasm ../summrise-desktop-electron/src/summrise_shell_policy.js ../summrise-desktop-electron/src/summrise_shell_policy_bg.wasm ./summrise-desktop-electron/src/ && rm -rf /tmp/summrise-electron-fresh"
|
|
29
34
|
}
|
|
30
35
|
}
|
package/summrise-agent.exe
CHANGED
|
Binary file
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.browserSessionsBind = browserSessionsBind;
|
|
4
|
+
exports.browserOpen = browserOpen;
|
|
5
|
+
exports.browserClose = browserClose;
|
|
6
|
+
exports.browserList = browserList;
|
|
7
|
+
exports.browserCloseAll = browserCloseAll;
|
|
8
|
+
// ── Browser-session windows: the cluster that owns them (round-247) ────────────────────────────
|
|
9
|
+
// `main.ts`'s browser-session section, moved out whole: the three operations an AI drives over IPC or
|
|
10
|
+
// the loopback control server (`browserOpen`, `browserClose`, `browserList`), the session table they
|
|
11
|
+
// read and write, and the shutdown sweep that closes it. What those operations DECIDE is not here, and
|
|
12
|
+
// never was: the reuse/evict plan is `agent/summrise-shell-policy`'s (`planBrowserOpen`), the window id
|
|
13
|
+
// is its `browserId`, the CDP endpoint they advertise is its `cdpEndpoint`, and the cap is its
|
|
14
|
+
// `MAX_BROWSER_WINDOWS`. This file applies those answers to real `BrowserWindow`s.
|
|
15
|
+
//
|
|
16
|
+
// ── WHY THIS IS A FILE OF ITS OWN, AND THE THREE THINGS IT DOES NOT OWN ────────────────────────
|
|
17
|
+
// The survey that found it measured that nothing here touches `tray`, `Tray`, `Menu`, `mainWindow`,
|
|
18
|
+
// `ipcMain` or the `win` variable — and it also measured what a survey of NAMES misses: the section has
|
|
19
|
+
// THREE dependencies on `main.ts`, and they are why this file takes a seam rather than an import.
|
|
20
|
+
// * the ONE load door (`loadTarget`) — `main.ts`'s single call site of the URL policy's
|
|
21
|
+
// `sanitizeBrowserUrl`. Every URL a session window opens must go through it, never around it.
|
|
22
|
+
// * the window icon (`windowIcon`) — the shell's own resolution of `windowIconName()` against the
|
|
23
|
+
// install tree, which is `main.ts`'s business (it records what it resolved for
|
|
24
|
+
// `/api/shell/icon-status`) and is shared with the main window.
|
|
25
|
+
// * the window cap (`maxBrowserWindows`) — `K.MAX_BROWSER_WINDOWS`, the boot table `main.ts` parses
|
|
26
|
+
// once from the policy crate. One number, fixed at boot: a VALUE, not a getter.
|
|
27
|
+
// Importing any of the three from `main.ts` would make `main.ts` require this module and this module
|
|
28
|
+
// require `main.ts`: a cycle whose failure mode is a half-initialised module rather than an error. So
|
|
29
|
+
// `main.ts` binds all three ONCE (`browserSessionsBind`) and this module reads them through `deps()`.
|
|
30
|
+
//
|
|
31
|
+
// THE IPC REGISTRATIONS DELIBERATELY DID NOT MOVE WITH IT. They go through `main.ts`'s ONE door
|
|
32
|
+
// (`ipcHandle`, which applies the frame check and is the file's only `ipcMain.handle` call), and the
|
|
33
|
+
// channel set is pinned against the preload's by `test/ipc-door.test.mjs`. A handler registered here
|
|
34
|
+
// would be a second door, so `main.ts` keeps the three `ipcHandle("browser-session:…")` lines and calls
|
|
35
|
+
// in — and with them the THREE other call sites the section always had: the loopback control server's
|
|
36
|
+
// `browser-open`/`browser-close`/`browser-list` routes (the SPA in a plain browser) and the main
|
|
37
|
+
// window's `setWindowOpenHandler`, which routes a `target=_blank` from the panel to a sandboxed session
|
|
38
|
+
// instead of a preload-bearing window.
|
|
39
|
+
const electron_1 = require("electron");
|
|
40
|
+
const summrise_shell_policy_1 = require("./summrise_shell_policy");
|
|
41
|
+
/** The CDP port every session window is advertised on. Read from the SAME boot table `main.ts` parses,
|
|
42
|
+
* rather than bound: it is a policy constant, not host state — `main.ts` does not own 9333, it passes
|
|
43
|
+
* it to `--remote-debugging-port` and `agent/summrise-shell-policy`'s `boot.rs` defines it. Bound state
|
|
44
|
+
* is for what the host OWNS; a second reader of the crate's table is not a second decision. */
|
|
45
|
+
const CDP_PORT = JSON.parse((0, summrise_shell_policy_1.shellConstants)()).CDP_PORT;
|
|
46
|
+
let bound = null;
|
|
47
|
+
/** Called ONCE by `main.ts`, before any `browser-session:*` channel can be invoked. */
|
|
48
|
+
function browserSessionsBind(host) { bound = host; }
|
|
49
|
+
function deps() {
|
|
50
|
+
// LOUD, not silent: an unbound host would otherwise open a window on a fallback URL, with no icon and
|
|
51
|
+
// no cap — the failures a reader would have to reconstruct from a browser that went to the wrong page.
|
|
52
|
+
if (!bound)
|
|
53
|
+
throw new Error("browser-sessions: browserSessionsBind() was never called — main.ts binds the shell's load door, its window icon and its session cap at startup");
|
|
54
|
+
return bound;
|
|
55
|
+
}
|
|
56
|
+
const browserSessions = new Map();
|
|
57
|
+
/** Initial target per browser window — reuse matching uses this instead of
|
|
58
|
+
* webContents.getURL() (which lags during navigation; stage-n). */
|
|
59
|
+
const browserTargets = new Map();
|
|
60
|
+
/** Open a browser-session window on a decided load target (loadTarget() is the
|
|
61
|
+
* ONE load door — the scheme policy lives there). */
|
|
62
|
+
function browserOpen(url) {
|
|
63
|
+
const target = deps().loadTarget(url);
|
|
64
|
+
// stage-n: reuse an existing window on the same URL instead of stacking duplicates (AI-driven
|
|
65
|
+
// browsing opens/closes sessions repeatedly). Match against the window's INITIAL target (stored at
|
|
66
|
+
// open) — webContents.getURL() lags during navigation (returns about:blank while loading), so a
|
|
67
|
+
// same-URL reopen right after the first open would otherwise miss the reuse and stack a duplicate.
|
|
68
|
+
//
|
|
69
|
+
// THE PLAN IS THE POLICY CRATE'S, in one call: which window to reuse (first non-destroyed match, in
|
|
70
|
+
// INSERTION order), and — when the cap is reached — which one to evict (the OLDEST, not the least
|
|
71
|
+
// recently used). This file only reads the session table and applies the answer. The `destroyed` flag
|
|
72
|
+
// travels with the row because a destroyed window is skipped for reuse while still COUNTING against
|
|
73
|
+
// the cap, which is what `browserSessions.size` did.
|
|
74
|
+
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, deps().maxBrowserWindows));
|
|
75
|
+
if (plan.reuse) {
|
|
76
|
+
const existing = browserSessions.get(plan.reuse);
|
|
77
|
+
if (existing) {
|
|
78
|
+
if (existing.isMinimized())
|
|
79
|
+
existing.restore();
|
|
80
|
+
existing.show();
|
|
81
|
+
existing.focus();
|
|
82
|
+
return { ok: true, id: plan.reuse, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(CDP_PORT) };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (plan.evict)
|
|
86
|
+
browserClose(plan.evict);
|
|
87
|
+
const id = (0, summrise_shell_policy_1.browserId)(Date.now());
|
|
88
|
+
const bw = new electron_1.BrowserWindow({
|
|
89
|
+
width: 1100, height: 750, title: `Summrise Browser — ${target}`,
|
|
90
|
+
...(deps().windowIcon() ? { icon: deps().windowIcon() } : {}),
|
|
91
|
+
// review #6/#7: these windows load ARBITRARY internet pages. contextIsolation
|
|
92
|
+
// on + nodeIntegration off + NO preload = zero Node/bridge surface (the
|
|
93
|
+
// default; made explicit). Popups are denied (they would otherwise spawn
|
|
94
|
+
// unmanaged windows outside the MAX_BROWSER_WINDOWS cap).
|
|
95
|
+
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
|
96
|
+
});
|
|
97
|
+
bw.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
98
|
+
bw.loadURL(target);
|
|
99
|
+
bw.on("closed", () => { browserSessions.delete(id); browserTargets.delete(id); });
|
|
100
|
+
// stage-n: the window title starts as "Summrise Browser — <target>" but a
|
|
101
|
+
// site's own <title> is more useful after load (and after in-page
|
|
102
|
+
// navigation); follow it so window managers/taskbar show the real page.
|
|
103
|
+
bw.webContents.on("page-title-updated", (_e, title) => {
|
|
104
|
+
if (!bw.isDestroyed() && title)
|
|
105
|
+
bw.setTitle(`Summrise Browser — ${title}`);
|
|
106
|
+
});
|
|
107
|
+
browserSessions.set(id, bw);
|
|
108
|
+
browserTargets.set(id, target);
|
|
109
|
+
return { ok: true, id, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(CDP_PORT) };
|
|
110
|
+
}
|
|
111
|
+
function browserClose(id) {
|
|
112
|
+
const bw = browserSessions.get(id || "");
|
|
113
|
+
// review #g: close() is vetoable by a page's beforeunload — an arbitrary
|
|
114
|
+
// remote page could refuse eviction, so the MAX_BROWSER_WINDOWS cap never
|
|
115
|
+
// freed and browserOpen stalled. destroy() is unconditional (these are
|
|
116
|
+
// disposable, headless-driven windows).
|
|
117
|
+
if (bw) {
|
|
118
|
+
bw.destroy();
|
|
119
|
+
browserSessions.delete(id || "");
|
|
120
|
+
browserTargets.delete(id || "");
|
|
121
|
+
}
|
|
122
|
+
return { ok: true };
|
|
123
|
+
}
|
|
124
|
+
function browserList() {
|
|
125
|
+
const list = [...browserSessions.entries()].map(([id, bw]) => ({ id, url: bw.webContents.getURL() }));
|
|
126
|
+
return { ok: true, sessions: list, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(CDP_PORT) };
|
|
127
|
+
}
|
|
128
|
+
/** stage-n: close every browser-session window explicitly — app.quit()
|
|
129
|
+
* tears down the main window but independent BrowserWindows with pending
|
|
130
|
+
* beforeunload handlers can stall or leak on exit. The table is THIS file's state, so the sweep over it
|
|
131
|
+
* is too: `main.ts`'s `before-quit` handler calls in, the way it calls the three operations above. */
|
|
132
|
+
function browserCloseAll() {
|
|
133
|
+
for (const [id, bw] of browserSessions) {
|
|
134
|
+
if (!bw.isDestroyed())
|
|
135
|
+
bw.destroy();
|
|
136
|
+
browserSessions.delete(id);
|
|
137
|
+
browserTargets.delete(id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.embeddedSlot = embeddedSlot;
|
|
4
|
+
// ── ONE EMBEDDED SLOT, INSTANTIATED TWICE ──────────────────────────────────────────────────────
|
|
5
|
+
// The shell embeds TWO native `WebContentsView`s over the SPA's placeholders: the real-render BROWSER
|
|
6
|
+
// view (an internet browser an AI also drives over CDP) and the DSH view (the operator's own harness on
|
|
7
|
+
// loopback). They arrived here as two modules — `embedded-view.ts` and `dsh-view.ts` — that a
|
|
8
|
+
// function-by-function comparison (`docs/agents/ideas.md`, round 111) measured to be near-copies of one
|
|
9
|
+
// pattern: the same eight twinned operations (`bind`/`deps`/`ensure`/`place`/`navigate`/`reload`/
|
|
10
|
+
// `recover`/`state`), the same three slot-state bindings, and ELEVEN occurrences of
|
|
11
|
+
// `setVisible`/`setBounds`/`addChildView`/`removeChildView` on each side. This file is that pattern,
|
|
12
|
+
// ONCE, as a factory: `embeddedSlot(config)` returns one slot's functions, and `main.ts` builds both
|
|
13
|
+
// instances from it.
|
|
14
|
+
//
|
|
15
|
+
// ── WHAT IS A PARAMETER, AND WHY EACH ONE CANNOT BE MERGED AWAY ────────────────────────────────
|
|
16
|
+
// * `door` — the ONE load door for this slot: `loadTarget` (the url policy's `sanitizeBrowserUrl`,
|
|
17
|
+
// admitting any http/https) for the browser, `dshTargetFor` (its own loopback origin, or nothing)
|
|
18
|
+
// for the DSH. Forced by the crate split: the two policy crates must not depend on each other, so
|
|
19
|
+
// shell-policy takes the ANSWER of url-policy's predicate. Merging them would either expose the
|
|
20
|
+
// internet to the DSH's `window.open` or confine the browser view to loopback.
|
|
21
|
+
// * `popupTarget` — the `target=_blank` decision, over the same split as `door`:
|
|
22
|
+
// `embeddedPopupTarget(door(raw))` for the browser (its argument is the DECIDED target, and a
|
|
23
|
+
// refusal is DROPPED rather than collapsed to `about:blank`, so a refused link cannot erase the page
|
|
24
|
+
// being read) against `dshPopupTarget(raw, isDshUrl(raw))` for the DSH.
|
|
25
|
+
// * `recoverUrl(lastDecided)` — what a crashed view is reloaded with: the browser's `embeddedRecoverUrl`
|
|
26
|
+
// (the last DECIDED url, `about:blank` as the fallback, because "recovering a crashed view must not
|
|
27
|
+
// make a request to a search engine to do it") against the DSH's `dshHome(base)` ("a crash recovery
|
|
28
|
+
// that lands somewhere other than the harness is a view the operator did not ask for").
|
|
29
|
+
// * `stateUrl(live, lastDecided)` — the `url` field of `state()`. The browser's address bar must never
|
|
30
|
+
// go blank, so it answers the last decided url when its view cannot (`shownUrl`); the DSH's record
|
|
31
|
+
// must not report a dead object's last url, so it answers "" (`shownUrl(live, "")`). This is a
|
|
32
|
+
// FINDING rather than one of the four load-bearing differences above: the two modules differed here
|
|
33
|
+
// by accident of their callers, and merging it either way would change one pane's contract.
|
|
34
|
+
// * `goneChannel` — `embedded-browser:gone` / `embedded-dsh:gone`. The signal is the same on both
|
|
35
|
+
// sides (a renderer that died must not leave the pane saying "starting…" forever); only the channel
|
|
36
|
+
// name differs, and the names are pinned by the preload/panel fixtures.
|
|
37
|
+
// * `navChannel?` — present ONLY for the browser: it brings the three navigation pushes
|
|
38
|
+
// (`did-navigate`, `did-navigate-in-page`, `page-title-updated`), the `canBack`/`canFwd` fields of
|
|
39
|
+
// `state()`, and the `go`/`zoom` operations. The DSH has no history surface, so adding them there
|
|
40
|
+
// would be uncalled surface (and a channel the preload does not expose would fail the channel-set
|
|
41
|
+
// equality in `test/ipc-door.test.mjs`).
|
|
42
|
+
//
|
|
43
|
+
// ── WHAT IS DELIBERATELY NOT A PARAMETER, AND WHY ──────────────────────────────────────────────
|
|
44
|
+
// THE LIFECYCLE AND THE STARTUP NAVIGATION ARE CALLER BEHAVIOURS. The browser slot is created EAGERLY at
|
|
45
|
+
// startup, hidden, and never navigated (an attached AI always has an `about:blank` CDP target; it used
|
|
46
|
+
// to load `bing.com` on every launch) — `main.ts` calls `ensure()` from `app.whenReady()`. The DSH slot
|
|
47
|
+
// is created LAZILY by the pane's `open()` and navigated home — `main.ts`'s `dshOpen()` is
|
|
48
|
+
// `slot.navigate(dshHome(dshBase()))`, and `navigate()` is what creates the view. Neither belongs in a
|
|
49
|
+
// parameter of `ensure()`: a flag here would let one caller ask for the other's lifecycle by accident.
|
|
50
|
+
// `main.ts` also keeps `bootDshPort`, `dshTargetFor`, `loadHarnessDoors`, `dshOpen` and EVERY
|
|
51
|
+
// `ipcHandle(...)` registration, because the shell holds the ONE `ipcMain.handle` door.
|
|
52
|
+
//
|
|
53
|
+
// A POLICY THAT IS THE SAME FOR BOTH SLOTS STAYS AN IMPORT, not a parameter: the minimum slot
|
|
54
|
+
// (`slotTooSmall`), the visible conjunction (`viewVisible`), the history sign (`goBackwards`) and the
|
|
55
|
+
// zoom clamp (`zoomFactor`) are the shell policy crate's answers and are identical for the two views.
|
|
56
|
+
// Only what DIFFERS travels as a parameter.
|
|
57
|
+
//
|
|
58
|
+
// ── FIVE THINGS THE TWO IMPLEMENTATIONS DISAGREED ABOUT THAT WERE NOT IN THE LIST ABOVE ────────
|
|
59
|
+
// Each was decided here explicitly rather than inherited from whichever side happened to be copied:
|
|
60
|
+
// 1. THE RE-SHOW AFTER A CRASH. Both `recover()`s contained a "re-show if the slot is live" block that
|
|
61
|
+
// could never run: `ensure()` — which recovery has just called to build the fresh view — clears the
|
|
62
|
+
// visible flag first. It is FIXED here rather than deleted: the block's own comment states its
|
|
63
|
+
// purpose ("kept so a renderer-crash recovery can re-show the fresh view at the same spot without
|
|
64
|
+
// waiting for the SPA"), the retained bounds exist for exactly that, and the fix makes the slot's
|
|
65
|
+
// record agree with its screen (the flag is restored, so `state().visible` is true when the fresh
|
|
66
|
+
// view is shown). It is not a new always-on behaviour: after a REAL crash the `gone` handler has
|
|
67
|
+
// already cleared the flag, so the SPA's next `place()` still shows the view — this only covers a
|
|
68
|
+
// recovery invoked without that event (an explicit recover, or a stalled renderer).
|
|
69
|
+
// 2. `ensure()` WAS EXPORTED BY ONE MODULE AND PRIVATE IN THE OTHER. The slot exposes it for both: it
|
|
70
|
+
// is the browser's eager-creation hook, and the difference is which CALLER calls it, not what it
|
|
71
|
+
// does. The DSH's instance is simply never asked.
|
|
72
|
+
// 3. THE PERMISSION HANDLERS. The browser's `ensure()` installed deny-by-default request/check
|
|
73
|
+
// handlers on the session; the DSH's installed none. Both instances install them now: the DSH is
|
|
74
|
+
// still a whole web application on loopback, and the DSH's own suite already called the posture it
|
|
75
|
+
// lacked "the browser view's security posture". The calls are idempotent and inside the same
|
|
76
|
+
// try/catch, so a session that refuses them is non-fatal on both sides.
|
|
77
|
+
// 4. THE `gone` HANDLER'S ORDER. The browser checked the window FIRST and returned — leaving a dead
|
|
78
|
+
// renderer's view visible — where the DSH hid the view first and only the PUSH depended on the
|
|
79
|
+
// window. The DSH's order is the rule here, on both slots: a renderer that died never repaints, the
|
|
80
|
+
// hide needs nothing from the host, and the window (which `main.ts` re-creates and nulls) is read
|
|
81
|
+
// only where it is genuinely required.
|
|
82
|
+
// 5. `state()`'s `visible` FIELD reached the crate on one side (`viewVisible`) and was spelled
|
|
83
|
+
// `dshVisible && !!wc` on the other. The two are the same answer, so both go through the crate —
|
|
84
|
+
// the decision belongs to the policy, not to one call site.
|
|
85
|
+
const electron_1 = require("electron");
|
|
86
|
+
const summrise_shell_policy_1 = require("./summrise_shell_policy");
|
|
87
|
+
function embeddedSlot(config) {
|
|
88
|
+
if (!config || typeof config !== "object") {
|
|
89
|
+
throw new Error("embedded-slot: a slot is built from its host config (win, door, popupTarget, recoverUrl, stateUrl, " +
|
|
90
|
+
"goneChannel, and navChannel for a slot with a history surface) — there is no such thing as a default one");
|
|
91
|
+
}
|
|
92
|
+
const REQUIRED = ["win", "door", "popupTarget", "recoverUrl", "stateUrl"];
|
|
93
|
+
for (const name of REQUIRED) {
|
|
94
|
+
if (typeof config[name] !== "function") {
|
|
95
|
+
throw new Error(`embedded-slot: ${name} is not a function — a slot is built with its host (main.ts lends both ` +
|
|
96
|
+
`instances the shell's window getter, one load door, and the policy answers only that slot may use)`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (typeof config.goneChannel !== "string" || !config.goneChannel) {
|
|
100
|
+
throw new Error("embedded-slot: goneChannel is missing — a crashed renderer pushes `gone` so the pane can show a " +
|
|
101
|
+
"recovery state instead of 'starting…' forever, and a slot that cannot push it must not be built");
|
|
102
|
+
}
|
|
103
|
+
if (config.navChannel !== undefined && (typeof config.navChannel !== "string" || !config.navChannel)) {
|
|
104
|
+
throw new Error("embedded-slot: navChannel is present but is not a channel name — omit it for a slot with no history surface");
|
|
105
|
+
}
|
|
106
|
+
const { win, door, popupTarget, recoverUrl, stateUrl, goneChannel, navChannel } = config;
|
|
107
|
+
// ── THE PER-INSTANCE STATE, AND IT IS THE INSTANCE'S ─────────────────────────────────────────
|
|
108
|
+
// Four bindings, closed over rather than module-level, which is what makes two slots independent: the
|
|
109
|
+
// two modules this replaces each held their state at module scope, so a second instance was not
|
|
110
|
+
// merely unused, it was unrepresentable. (`embeddedUrl` is the fourth rather than the third: the
|
|
111
|
+
// browser's module held view/visible/bounds AND the last url its door decided, which recovery and
|
|
112
|
+
// `state()` both read. The DSH's recover ignores it and its `stateUrl` does too.)
|
|
113
|
+
let view = null;
|
|
114
|
+
let visible = false;
|
|
115
|
+
let bounds = null;
|
|
116
|
+
let lastDecided = "about:blank";
|
|
117
|
+
function ensure() {
|
|
118
|
+
if (view && !view.webContents.isDestroyed())
|
|
119
|
+
return view;
|
|
120
|
+
// ── THE HOST IS READ BEFORE ANYTHING IS BUILT, AND BEFORE ANY STATE IS SET ─────────────────────
|
|
121
|
+
// An UNBOUND slot is one whose window getter refuses (`deps().win()` on a host whose `bind()` never
|
|
122
|
+
// ran), and a refusal must leave the slot exactly as it was: nothing built, nothing assigned, no
|
|
123
|
+
// handler wired. The read used to sit AFTER `view = built` — the order BOTH modules had before the
|
|
124
|
+
// unification — so the loud throw left a fully built view in the slot's state that no window ever
|
|
125
|
+
// showed: its crash handler was live, a later `state()`/`reload()` answered about it, and a second
|
|
126
|
+
// `ensure()` returned it without reading the window again. Nothing below needs the window to BUILD a
|
|
127
|
+
// view; the read is only about WHERE to attach one, so it happens first and a refusal creates
|
|
128
|
+
// nothing. This is a deliberate behaviour change on the UNBOUND path only: a bound host's getter
|
|
129
|
+
// returns exactly what it returned before, and reads it exactly once.
|
|
130
|
+
const w = win();
|
|
131
|
+
const built = new electron_1.WebContentsView({
|
|
132
|
+
webPreferences: {
|
|
133
|
+
contextIsolation: true,
|
|
134
|
+
nodeIntegration: false,
|
|
135
|
+
sandbox: true,
|
|
136
|
+
// NO `preload` KEY, on either slot: zero Node surface in a renderer. The browser view renders
|
|
137
|
+
// arbitrary internet pages; the DSH is a whole web application on loopback. A `preload` added
|
|
138
|
+
// here would be added to BOTH views at once, which is why it is written down.
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
built.setVisible(false);
|
|
142
|
+
visible = false;
|
|
143
|
+
// ── `target=_blank`, ONE DECISION PER SLOT ─────────────────────────────────────────────────
|
|
144
|
+
// Real sites open every external link in a new window (Baidu's, the harness's docs), and this is a
|
|
145
|
+
// single-tab pane: the popup itself is always DENIED and the SAME view moves instead. WHICH targets
|
|
146
|
+
// qualify is the `popupTarget` parameter's — through the slot's own door, so a refused scheme is
|
|
147
|
+
// dropped rather than navigated to `about:blank`.
|
|
148
|
+
built.webContents.setWindowOpenHandler(({ url }) => {
|
|
149
|
+
const decided = popupTarget(url);
|
|
150
|
+
if (decided !== undefined)
|
|
151
|
+
navigateDecided(decided);
|
|
152
|
+
return { action: "deny" };
|
|
153
|
+
});
|
|
154
|
+
if (navChannel) {
|
|
155
|
+
// Event-driven (no polling): the SPA's address bar and back/fwd buttons track the ACTUAL page, so
|
|
156
|
+
// an in-page (hash) navigation and a title-only update are changes it has to see too.
|
|
157
|
+
const push = () => {
|
|
158
|
+
const w = win();
|
|
159
|
+
if (!w || w.isDestroyed())
|
|
160
|
+
return;
|
|
161
|
+
const s = stateWithHistory();
|
|
162
|
+
w.webContents.send(navChannel, { url: s.url, canBack: s.canBack, canFwd: s.canFwd, title: s.title });
|
|
163
|
+
};
|
|
164
|
+
built.webContents.on("did-navigate", push);
|
|
165
|
+
built.webContents.on("did-navigate-in-page", push);
|
|
166
|
+
built.webContents.on("page-title-updated", push);
|
|
167
|
+
}
|
|
168
|
+
// A renderer that crashed (process-gone) or hung mid-load never paints again. The dead view is HIDDEN
|
|
169
|
+
// first — that needs nothing from the host — so the pane's recovery banner, which paints under the
|
|
170
|
+
// native view, becomes visible in the slot; only the PUSH needs a live window, which `main.ts`
|
|
171
|
+
// re-creates and nulls (a destroyed window's `send` throws in Electron).
|
|
172
|
+
built.webContents.on("render-process-gone", (_e, details) => {
|
|
173
|
+
try {
|
|
174
|
+
built.setVisible(false);
|
|
175
|
+
}
|
|
176
|
+
catch { /* already gone */ }
|
|
177
|
+
visible = false;
|
|
178
|
+
const w = win();
|
|
179
|
+
if (w && !w.isDestroyed()) {
|
|
180
|
+
w.webContents.send(goneChannel, { reason: details.reason, exitCode: details.exitCode });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
try {
|
|
184
|
+
electron_1.session.defaultSession.setPermissionRequestHandler((_wc, _perm, cb) => cb(false));
|
|
185
|
+
electron_1.session.defaultSession.setPermissionCheckHandler(() => false);
|
|
186
|
+
}
|
|
187
|
+
catch { /* non-fatal (already set at app ready, or a session that does not exist) */ }
|
|
188
|
+
view = built;
|
|
189
|
+
// Attach to the main window's content view once the window exists — THE WINDOW READ AT THE TOP of
|
|
190
|
+
// this function, not a second read: the same one getter call, moved ahead of the build. The view is
|
|
191
|
+
// added HIDDEN and only shown when the SPA's page is active and reports its placeholder bounds.
|
|
192
|
+
if (w)
|
|
193
|
+
w.contentView.addChildView(built);
|
|
194
|
+
return built;
|
|
195
|
+
}
|
|
196
|
+
/** Place the view over the SPA's slot (CSS px in the window's content coordinate space). Empty bounds
|
|
197
|
+
* hide it. */
|
|
198
|
+
function place(next) {
|
|
199
|
+
const v = ensure();
|
|
200
|
+
const w = win();
|
|
201
|
+
if (!w)
|
|
202
|
+
return;
|
|
203
|
+
if ((0, summrise_shell_policy_1.slotTooSmall)(next)) {
|
|
204
|
+
v.setVisible(false);
|
|
205
|
+
visible = false;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
bounds = next;
|
|
209
|
+
v.setBounds(next);
|
|
210
|
+
v.setVisible(true);
|
|
211
|
+
visible = true;
|
|
212
|
+
// Keep it above the SPA content (the SPA's placeholder is an empty div).
|
|
213
|
+
w.contentView.addChildView(v);
|
|
214
|
+
}
|
|
215
|
+
/** Load an ALREADY DECIDED url. The one path that does not open the door again: the popup decision and
|
|
216
|
+
* `recover()` both hand this a target their own policy has decided, and re-deciding it would be a
|
|
217
|
+
* second call the caller did not ask for. */
|
|
218
|
+
function navigateDecided(url) {
|
|
219
|
+
const v = ensure();
|
|
220
|
+
lastDecided = url;
|
|
221
|
+
v.webContents.loadURL(url).catch(() => { });
|
|
222
|
+
}
|
|
223
|
+
function navigate(raw) {
|
|
224
|
+
// Every raw url the SPA or the AI hands this slot is decided by the ONE load door before it reaches
|
|
225
|
+
// loadURL — and it is recorded FIRST, so the record is the door's answer and never the raw string.
|
|
226
|
+
const url = door(raw);
|
|
227
|
+
navigateDecided(url);
|
|
228
|
+
return { ok: true, url };
|
|
229
|
+
}
|
|
230
|
+
function reload() {
|
|
231
|
+
const v = view;
|
|
232
|
+
if (!v || v.webContents.isDestroyed())
|
|
233
|
+
return { ok: false };
|
|
234
|
+
try {
|
|
235
|
+
v.webContents.reload();
|
|
236
|
+
return { ok: true };
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return { ok: false };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** The record WITHOUT a history surface: exactly the four keys the DSH's pane reads. */
|
|
243
|
+
function stateRecord() {
|
|
244
|
+
const v = view;
|
|
245
|
+
const wc = v && !v.webContents.isDestroyed() ? v.webContents : null;
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
url: stateUrl(wc ? wc.getURL() : undefined, lastDecided),
|
|
249
|
+
title: wc ? wc.getTitle() : "",
|
|
250
|
+
visible: (0, summrise_shell_policy_1.viewVisible)(visible, !!wc),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** ...and the same record WITH the two history fields, which only the browser's slot has. */
|
|
254
|
+
function stateWithHistory() {
|
|
255
|
+
const v = view;
|
|
256
|
+
const wc = v && !v.webContents.isDestroyed() ? v.webContents : null;
|
|
257
|
+
return {
|
|
258
|
+
...stateRecord(),
|
|
259
|
+
canBack: !!wc && wc.navigationHistory.canGoBack(),
|
|
260
|
+
canFwd: !!wc && wc.navigationHistory.canGoForward(),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** Recover after a renderer crash: a dead webContents never recovers, so re-create the view. */
|
|
264
|
+
function recover() {
|
|
265
|
+
// THE SLOT'S PLACEMENT IS READ BEFORE THE RE-CREATE. `ensure()` clears the visible flag for the view
|
|
266
|
+
// it builds, so a re-show block that reads the flag afterwards can never run — which is exactly the
|
|
267
|
+
// defect both modules carried. Reading it here is what makes the re-show below reachable, and the
|
|
268
|
+
// bounds are retained on every hide for the same reason (they are the SPA's last placement).
|
|
269
|
+
const wasVisible = visible;
|
|
270
|
+
const lastBounds = bounds;
|
|
271
|
+
try {
|
|
272
|
+
if (view && !view.webContents.isDestroyed()) {
|
|
273
|
+
view.webContents.close({ waitForBeforeUnload: false });
|
|
274
|
+
}
|
|
275
|
+
if (view) {
|
|
276
|
+
try {
|
|
277
|
+
win()?.contentView.removeChildView(view);
|
|
278
|
+
}
|
|
279
|
+
catch { /* already gone */ }
|
|
280
|
+
view = null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch { /* fall through to ensure() */ }
|
|
284
|
+
const fresh = ensure();
|
|
285
|
+
if (!fresh || fresh.webContents.isDestroyed())
|
|
286
|
+
return { ok: false };
|
|
287
|
+
// The recovery target is the parameter's, and it is ALREADY A DECISION: the browser's re-loads the
|
|
288
|
+
// last url its door decided (never a third-party home page a crash would fetch), the DSH's is its
|
|
289
|
+
// own home (`dshHome`), because a recovery that lands anywhere else is a view nobody asked for.
|
|
290
|
+
fresh.webContents.loadURL(recoverUrl(lastDecided)).catch(() => { });
|
|
291
|
+
// Re-show at the same spot if the slot was live when the renderer died — and restore the flag, so the
|
|
292
|
+
// record the pane reads agrees with what is on screen. A slot that was hidden (never placed, or the
|
|
293
|
+
// SPA cleared its placeholder) stays hidden: the SPA's next place() is what shows it.
|
|
294
|
+
if (wasVisible && lastBounds) {
|
|
295
|
+
const w = win();
|
|
296
|
+
if (w) {
|
|
297
|
+
fresh.setBounds(lastBounds);
|
|
298
|
+
fresh.setVisible(true);
|
|
299
|
+
visible = true;
|
|
300
|
+
w.contentView.addChildView(fresh);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { ok: true };
|
|
304
|
+
}
|
|
305
|
+
const slot = { ensure, place, navigate, reload, recover, state: stateRecord };
|
|
306
|
+
if (!navChannel)
|
|
307
|
+
return slot;
|
|
308
|
+
// THE HISTORY SURFACE, only for the slot that declared a nav channel. A pane with no address bar and no
|
|
309
|
+
// back/fwd buttons has no use for these, and a channel nothing invokes is a channel the preload does not
|
|
310
|
+
// expose — which `test/ipc-door.test.mjs` fails on.
|
|
311
|
+
const nav = {
|
|
312
|
+
...slot,
|
|
313
|
+
state: stateWithHistory,
|
|
314
|
+
/** Back/forward through the REAL webContents history; the direction sign is the crate's. */
|
|
315
|
+
go: (delta) => {
|
|
316
|
+
const v = view;
|
|
317
|
+
if (!v || v.webContents.isDestroyed())
|
|
318
|
+
return { ok: false };
|
|
319
|
+
try {
|
|
320
|
+
if ((0, summrise_shell_policy_1.goBackwards)(delta))
|
|
321
|
+
v.webContents.goBack();
|
|
322
|
+
else
|
|
323
|
+
v.webContents.goForward();
|
|
324
|
+
return { ok: true };
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return { ok: false };
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
/** Zoom the REAL view (webContents.setZoomFactor — the native browser zooms, not a CSS scale). The
|
|
331
|
+
* clamp AND the falsy test are the crate's, because `zoomFactor(0)` is 100% and not 50%. */
|
|
332
|
+
zoom: (factor) => {
|
|
333
|
+
const v = view;
|
|
334
|
+
if (!v || v.webContents.isDestroyed())
|
|
335
|
+
return { ok: false };
|
|
336
|
+
const f = (0, summrise_shell_policy_1.zoomFactor)(factor);
|
|
337
|
+
try {
|
|
338
|
+
v.webContents.setZoomFactor(f);
|
|
339
|
+
return { ok: true, factor: f };
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return { ok: false };
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
return nav;
|
|
347
|
+
}
|