summrise-agent 1.2.511 → 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 +5 -3
- 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 +131 -452
- package/summrise-desktop-electron/src/summrise_shell_policy_bg.wasm +0 -0
- package/summrise-launch.exe +0 -0
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
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "summrise-agent",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.512",
|
|
4
4
|
"description": "Summrise Agent — Windows device MCP server (terminal/SSH/serial) + panel. Self-hosted npm distribution.",
|
|
5
|
-
"//build": "
|
|
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
7
|
"summrise": "bin/summrise.exe"
|
|
8
8
|
},
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"summrise-agent.exe",
|
|
13
13
|
"summrise-launch.exe",
|
|
14
14
|
"summrise-desktop-electron/src/main.js",
|
|
15
|
+
"summrise-desktop-electron/src/embedded-slot.js",
|
|
16
|
+
"summrise-desktop-electron/src/browser-sessions.js",
|
|
15
17
|
"summrise-desktop-electron/src/preload.js",
|
|
16
18
|
"summrise-desktop-electron/src/summrise_url_policy.js",
|
|
17
19
|
"summrise-desktop-electron/src/summrise_url_policy_bg.wasm",
|
|
@@ -28,6 +30,6 @@
|
|
|
28
30
|
],
|
|
29
31
|
"license": "MIT",
|
|
30
32
|
"scripts": {
|
|
31
|
-
"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/preload.js ../summrise-desktop-electron/src/preload.js && cp ../summrise-desktop-electron/src/main.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"
|
|
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"
|
|
32
34
|
}
|
|
33
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
|
+
}
|
|
@@ -82,6 +82,21 @@ const http = __importStar(require("http"));
|
|
|
82
82
|
// this file, which holds exactly one instance of each glue, evaluates it.
|
|
83
83
|
const summrise_url_policy_1 = require("./summrise_url_policy");
|
|
84
84
|
const summrise_shell_policy_1 = require("./summrise_shell_policy");
|
|
85
|
+
// THE TWO EMBEDDED VIEWS ARE ONE MODULE NOW. `embedded-slot.ts` holds the pattern the browser's view and
|
|
86
|
+
// the DSH's view each used to spell out (`embedded-view.ts` and `dsh-view.ts`, near-copies that a
|
|
87
|
+
// function-by-function comparison measured), and this file builds BOTH instances from it — one slot per
|
|
88
|
+
// view, with the differences that are load-bearing (the load door, the popup decision, the recovery
|
|
89
|
+
// target, the state's url fallback, the `gone` channel, and the browser-only nav channel) passed in as
|
|
90
|
+
// configuration. Every `ipcHandle` registration stays HERE (see the block below "THE EMBEDDED VIEW'S IPC"),
|
|
91
|
+
// because this file holds the ONE `ipcMain.handle` door.
|
|
92
|
+
const embedded_slot_1 = require("./embedded-slot");
|
|
93
|
+
// THE BROWSER-SESSION WINDOWS ARE THEIR OWN MODULE TOO. It owns the session table and the three
|
|
94
|
+
// operations an AI drives; this file owns the load door every one of them opens through, the window
|
|
95
|
+
// icon they share with the main window, and the cap's number — lent to it in one `browserSessionsBind`
|
|
96
|
+
// call below. The IPC registrations and the three other call sites stay HERE: this file holds the ONE
|
|
97
|
+
// `ipcMain.handle` door, and the loopback control server and the panel's `target=_blank` handler are
|
|
98
|
+
// this file's routers, not the module's.
|
|
99
|
+
const browser_sessions_1 = require("./browser-sessions");
|
|
85
100
|
const K = JSON.parse((0, summrise_shell_policy_1.shellConstants)());
|
|
86
101
|
// The handful the shell reads often enough that `K.` at every use site would be noise. They are still the
|
|
87
102
|
// table's values — destructuring a copy of the parse, not a second declaration.
|
|
@@ -235,13 +250,6 @@ let win = null;
|
|
|
235
250
|
let tray = null;
|
|
236
251
|
/** Whether the hide-to-tray notification has been shown (once per launch). */
|
|
237
252
|
let hideNotified = false;
|
|
238
|
-
const browserSessions = new Map();
|
|
239
|
-
/** Initial target per browser window — reuse matching uses this instead of
|
|
240
|
-
* webContents.getURL() (which lags during navigation; stage-n). */
|
|
241
|
-
const browserTargets = new Map();
|
|
242
|
-
/** Cap on concurrent browser-session windows — an AI loop opening sessions
|
|
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. */
|
|
245
253
|
// stage-m: SINGLE-INSTANCE LOCK — a second Summrise window exits immediately.
|
|
246
254
|
// (The agent itself also enforces single-instance via bind-failure exit.)
|
|
247
255
|
const gotTheLock = electron_1.app.requestSingleInstanceLock();
|
|
@@ -373,322 +381,60 @@ function buildMenu() {
|
|
|
373
381
|
* cannot quietly reach for a weaker policy of its own.
|
|
374
382
|
*
|
|
375
383
|
* Callers keep their own about:blank semantics: browserOpen() opens a blank
|
|
376
|
-
* window,
|
|
384
|
+
* window, the browser slot's navigate() loads it, and that slot's window-open
|
|
377
385
|
* handler REFUSES it (an external link must not blank the page being read). */
|
|
378
386
|
function loadTarget(raw) {
|
|
379
387
|
return (0, summrise_url_policy_1.sanitizeBrowserUrl)(raw);
|
|
380
388
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
function
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (plan.evict)
|
|
407
|
-
browserClose(plan.evict);
|
|
408
|
-
const id = (0, summrise_shell_policy_1.browserId)(Date.now());
|
|
409
|
-
const bw = new electron_1.BrowserWindow({
|
|
410
|
-
width: 1100, height: 750, title: `Summrise Browser — ${target}`,
|
|
411
|
-
...(windowIcon() ? { icon: windowIcon() } : {}),
|
|
412
|
-
// review #6/#7: these windows load ARBITRARY internet pages. contextIsolation
|
|
413
|
-
// on + nodeIntegration off + NO preload = zero Node/bridge surface (the
|
|
414
|
-
// default; made explicit). Popups are denied (they would otherwise spawn
|
|
415
|
-
// unmanaged windows outside the MAX_BROWSER_WINDOWS cap).
|
|
416
|
-
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
|
417
|
-
});
|
|
418
|
-
bw.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
419
|
-
bw.loadURL(target);
|
|
420
|
-
bw.on("closed", () => { browserSessions.delete(id); browserTargets.delete(id); });
|
|
421
|
-
// stage-n: the window title starts as "Summrise Browser — <target>" but a
|
|
422
|
-
// site's own <title> is more useful after load (and after in-page
|
|
423
|
-
// navigation); follow it so window managers/taskbar show the real page.
|
|
424
|
-
bw.webContents.on("page-title-updated", (_e, title) => {
|
|
425
|
-
if (!bw.isDestroyed() && title)
|
|
426
|
-
bw.setTitle(`Summrise Browser — ${title}`);
|
|
427
|
-
});
|
|
428
|
-
browserSessions.set(id, bw);
|
|
429
|
-
browserTargets.set(id, target);
|
|
430
|
-
return { ok: true, id, url: target, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
|
|
431
|
-
}
|
|
432
|
-
function browserClose(id) {
|
|
433
|
-
const bw = browserSessions.get(id || "");
|
|
434
|
-
// review #g: close() is vetoable by a page's beforeunload — an arbitrary
|
|
435
|
-
// remote page could refuse eviction, so the MAX_BROWSER_WINDOWS cap never
|
|
436
|
-
// freed and browserOpen stalled. destroy() is unconditional (these are
|
|
437
|
-
// disposable, headless-driven windows).
|
|
438
|
-
if (bw) {
|
|
439
|
-
bw.destroy();
|
|
440
|
-
browserSessions.delete(id || "");
|
|
441
|
-
browserTargets.delete(id || "");
|
|
442
|
-
}
|
|
443
|
-
return { ok: true };
|
|
444
|
-
}
|
|
445
|
-
function browserList() {
|
|
446
|
-
const list = [...browserSessions.entries()].map(([id, bw]) => ({ id, url: bw.webContents.getURL() }));
|
|
447
|
-
return { ok: true, sessions: list, cdp: (0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT) };
|
|
448
|
-
}
|
|
449
|
-
ipcHandle("browser-session:open", (url) => browserOpen(url));
|
|
450
|
-
ipcHandle("browser-session:close", (id) => browserClose(id));
|
|
451
|
-
ipcHandle("browser-session:list", () => browserList());
|
|
452
|
-
// ── Embedded real-render browser view (round-246) ──────────────────────────
|
|
453
|
-
// The SPA's Browser page used to show the BRIDGE's headless-chromium as a
|
|
454
|
-
// JPEG screencast (lossy q60-92 frames over a websocket) — never as sharp as
|
|
455
|
-
// a real browser, and a SECOND browser instance alongside the desktop shell.
|
|
456
|
-
// In the Electron shell we replace it with a REAL WebContentsView embedded
|
|
457
|
-
// over the SPA's browser placeholder: GPU-composited, vector text, directly
|
|
458
|
-
// interactive. Its webContents is a first-class CDP target on the SAME
|
|
459
|
-
// :9333 endpoint, so AI (playwright connectOverCDP) drives EXACTLY the page
|
|
460
|
-
// the user sees — one browser, zero JPEG.
|
|
461
|
-
//
|
|
462
|
-
// Security posture mirrors the browser-session windows: the view loads
|
|
463
|
-
// arbitrary internet pages, so contextIsolation on, nodeIntegration off, NO
|
|
464
|
-
// preload, sandbox on, popups denied, permissions denied by default.
|
|
465
|
-
let embeddedView = null;
|
|
466
|
-
let embeddedVisible = false;
|
|
467
|
-
let embeddedUrl = "about:blank";
|
|
468
|
-
// round-256: last placed bounds — kept so a renderer-crash recovery can
|
|
469
|
-
// re-show the fresh view at the same spot without waiting for the SPA.
|
|
470
|
-
let embeddedBounds = null;
|
|
471
|
-
function embeddedViewEnsure() {
|
|
472
|
-
if (embeddedView && !embeddedView.webContents.isDestroyed())
|
|
473
|
-
return embeddedView;
|
|
474
|
-
const view = new electron_1.WebContentsView({
|
|
475
|
-
webPreferences: {
|
|
476
|
-
contextIsolation: true,
|
|
477
|
-
nodeIntegration: false,
|
|
478
|
-
sandbox: true,
|
|
479
|
-
// round-246 (review #6/#7 parity): no preload = zero Node surface.
|
|
480
|
-
},
|
|
481
|
-
});
|
|
482
|
-
view.setVisible(false);
|
|
483
|
-
embeddedVisible = false;
|
|
484
|
-
// round-248 (user report "浏览器超链接跳转不了"): `target=_blank` links
|
|
485
|
-
// (the norm on real sites — Baidu's every external link opens a new
|
|
486
|
-
// window) were DENIED, so clicking them did nothing. The embedded view is
|
|
487
|
-
// a single-tab browser: intercept window.open and navigate the SAME view
|
|
488
|
-
// to the requested URL instead (the address bar follows via the nav
|
|
489
|
-
// events). The scheme allow-list is loadTarget()'s (the ONE load door):
|
|
490
|
-
// anything it refuses (javascript:, file:, chrome:) must NOT blank the page
|
|
491
|
-
// the user is reading, so a refused target is dropped here instead.
|
|
492
|
-
view.webContents.setWindowOpenHandler(({ url }) => {
|
|
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);
|
|
500
|
-
}
|
|
501
|
-
return { action: "deny" };
|
|
502
|
-
});
|
|
503
|
-
// round-248: same-view navigation for window.name/target=_top style links
|
|
504
|
-
// that Chromium routes as renderer-initiated top navigations is already
|
|
505
|
-
// handled natively; this handler only covers the window.open path above.
|
|
506
|
-
// round-247: real-navigation events → SPA (URL/title/history tracking).
|
|
507
|
-
embeddedWireNavEvents(view);
|
|
508
|
-
try {
|
|
509
|
-
electron_1.session.defaultSession.setPermissionRequestHandler((_wc, _perm, cb) => cb(false));
|
|
510
|
-
electron_1.session.defaultSession.setPermissionCheckHandler(() => false);
|
|
511
|
-
}
|
|
512
|
-
catch { /* non-fatal (already set at app ready) */ }
|
|
513
|
-
embeddedView = view;
|
|
514
|
-
// Attach to the main window's content view once the window exists. The
|
|
515
|
-
// view is added hidden and only shown when the SPA's Browser page is
|
|
516
|
-
// active and reports its placeholder bounds.
|
|
517
|
-
if (win)
|
|
518
|
-
win.contentView.addChildView(view);
|
|
519
|
-
return view;
|
|
520
|
-
}
|
|
521
|
-
/** Place the embedded view over the SPA's browser placeholder (CSS px in the
|
|
522
|
-
* window's content coordinate space). Empty bounds hide it. */
|
|
523
|
-
function embeddedViewPlace(bounds) {
|
|
524
|
-
const view = embeddedViewEnsure();
|
|
525
|
-
if (!win)
|
|
526
|
-
return;
|
|
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)) {
|
|
531
|
-
view.setVisible(false);
|
|
532
|
-
embeddedVisible = false;
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
embeddedBounds = bounds;
|
|
536
|
-
view.setBounds(bounds);
|
|
537
|
-
view.setVisible(true);
|
|
538
|
-
embeddedVisible = true;
|
|
539
|
-
// Keep it above the SPA content (the SPA placeholder is an empty div).
|
|
540
|
-
win.contentView.addChildView(view);
|
|
541
|
-
}
|
|
542
|
-
function embeddedNavigate(raw) {
|
|
543
|
-
// Every raw URL the SPA/AI hands the embedded view is decided by the ONE
|
|
544
|
-
// load door before it reaches loadURL.
|
|
545
|
-
const url = loadTarget(raw);
|
|
546
|
-
const view = embeddedViewEnsure();
|
|
547
|
-
embeddedUrl = url;
|
|
548
|
-
view.webContents.loadURL(url).catch(() => { });
|
|
549
|
-
return { ok: true, url };
|
|
550
|
-
}
|
|
551
|
-
/** round-247: nav controls on the embedded view (the SPA's toolbar mirrors a
|
|
552
|
-
* real browser: back/fwd/reload operate the actual webContents history). */
|
|
553
|
-
function embeddedGo(delta) {
|
|
554
|
-
const view = embeddedView;
|
|
555
|
-
if (!view || view.webContents.isDestroyed())
|
|
556
|
-
return { ok: false };
|
|
557
|
-
try {
|
|
558
|
-
if ((0, summrise_shell_policy_1.goBackwards)(delta))
|
|
559
|
-
view.webContents.goBack();
|
|
560
|
-
else
|
|
561
|
-
view.webContents.goForward();
|
|
562
|
-
return { ok: true };
|
|
563
|
-
}
|
|
564
|
-
catch {
|
|
565
|
-
return { ok: false };
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
function embeddedReload() {
|
|
569
|
-
const view = embeddedView;
|
|
570
|
-
if (!view || view.webContents.isDestroyed())
|
|
571
|
-
return { ok: false };
|
|
572
|
-
try {
|
|
573
|
-
view.webContents.reload();
|
|
574
|
-
return { ok: true };
|
|
575
|
-
}
|
|
576
|
-
catch {
|
|
577
|
-
return { ok: false };
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
function embeddedState() {
|
|
581
|
-
const view = embeddedView;
|
|
582
|
-
const wc = view && !view.webContents.isDestroyed() ? view.webContents : null;
|
|
583
|
-
return {
|
|
584
|
-
ok: true,
|
|
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),
|
|
588
|
-
canBack: !!wc && wc.navigationHistory.canGoBack(),
|
|
589
|
-
canFwd: !!wc && wc.navigationHistory.canGoForward(),
|
|
590
|
-
title: wc ? wc.getTitle() : "",
|
|
591
|
-
visible: (0, summrise_shell_policy_1.viewVisible)(embeddedVisible, !!wc),
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
/** round-247: push real navigation state (URL + title + history) to the SPA
|
|
595
|
-
* so its address bar and back/fwd buttons track the ACTUAL embedded page —
|
|
596
|
-
* event-driven (fires on navigation, no polling). */
|
|
597
|
-
function embeddedWireNavEvents(view) {
|
|
598
|
-
const wc = view.webContents;
|
|
599
|
-
const push = () => {
|
|
600
|
-
if (!win || win.isDestroyed())
|
|
601
|
-
return;
|
|
602
|
-
const s = embeddedState();
|
|
603
|
-
win.webContents.send("embedded-browser:nav", { url: s.url, canBack: s.canBack, canFwd: s.canFwd, title: s.title });
|
|
604
|
-
};
|
|
605
|
-
wc.on("did-navigate", push);
|
|
606
|
-
wc.on("did-navigate-in-page", push);
|
|
607
|
-
wc.on("page-title-updated", push);
|
|
608
|
-
// round-256: the embedded view's RENDERER can crash (process-gone) or hang
|
|
609
|
-
// mid-load (did-fail-load). Push a `gone` event so the SPA shows a recovery
|
|
610
|
-
// state instead of "Starting…" forever; the SPA then offers a reload.
|
|
611
|
-
wc.on("render-process-gone", (_e, details) => {
|
|
612
|
-
if (!win || win.isDestroyed())
|
|
613
|
-
return;
|
|
614
|
-
// Hide the dead view so the SPA's recovery banner (which paints under
|
|
615
|
-
// the native view) becomes visible in the slot.
|
|
616
|
-
try {
|
|
617
|
-
view.setVisible(false);
|
|
618
|
-
}
|
|
619
|
-
catch { /* already gone */ }
|
|
620
|
-
embeddedVisible = false;
|
|
621
|
-
win.webContents.send("embedded-browser:gone", {
|
|
622
|
-
reason: details.reason,
|
|
623
|
-
exitCode: details.exitCode,
|
|
624
|
-
});
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
/** round-256: recover the embedded view after a RENDERER CRASH. A
|
|
628
|
-
* process-gone renderer usually leaves the webContents object alive but
|
|
629
|
-
* dead — loadURL on it never recovers, so force a full re-create: drop the
|
|
630
|
-
* old view (destroy + remove) and build a fresh one via embeddedViewEnsure,
|
|
631
|
-
* then navigate to the last URL (or a default). */
|
|
632
|
-
function embeddedRecover() {
|
|
633
|
-
try {
|
|
634
|
-
if (embeddedView && !embeddedView.webContents.isDestroyed()) {
|
|
635
|
-
embeddedView.webContents.close({ waitForBeforeUnload: false });
|
|
636
|
-
}
|
|
637
|
-
if (embeddedView) {
|
|
638
|
-
try {
|
|
639
|
-
win?.contentView.removeChildView(embeddedView);
|
|
640
|
-
}
|
|
641
|
-
catch { /* already gone */ }
|
|
642
|
-
embeddedView = null;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
catch { /* fall through to ensure() */ }
|
|
646
|
-
const view = embeddedViewEnsure();
|
|
647
|
-
if (!view || view.webContents.isDestroyed())
|
|
648
|
-
return { ok: false };
|
|
649
|
-
// Already decided by loadTarget() when embeddedUrl was set (the ONE load
|
|
650
|
-
// door) — recovery re-loads that decision, it does not re-open the policy.
|
|
651
|
-
// The fallback is about:blank, NOT a third-party home page: recovering a
|
|
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);
|
|
655
|
-
view.webContents.loadURL(url).catch(() => { });
|
|
656
|
-
// Re-show if the SPA slot is live (bounds were placed before the crash).
|
|
657
|
-
if (embeddedVisible && win && embeddedBounds) {
|
|
658
|
-
view.setBounds(embeddedBounds);
|
|
659
|
-
view.setVisible(true);
|
|
660
|
-
win.contentView.addChildView(view);
|
|
661
|
-
}
|
|
662
|
-
return { ok: true };
|
|
663
|
-
}
|
|
664
|
-
ipcHandle("embedded-browser:recover", () => embeddedRecover());
|
|
665
|
-
ipcHandle("embedded-browser:navigate", (url) => embeddedNavigate(String(url || "")));
|
|
666
|
-
ipcHandle("embedded-browser:back", () => embeddedGo(-1));
|
|
667
|
-
ipcHandle("embedded-browser:fwd", () => embeddedGo(1));
|
|
668
|
-
ipcHandle("embedded-browser:reload", () => embeddedReload());
|
|
669
|
-
/** round-251: zoom the REAL embedded view (webContents.setZoomFactor — the
|
|
670
|
-
* native browser zooms, not a CSS scale). factor: 0.5-3.0. */
|
|
671
|
-
ipcHandle("embedded-browser:zoom", (factor) => {
|
|
672
|
-
const view = embeddedView;
|
|
673
|
-
if (!view || view.webContents.isDestroyed())
|
|
674
|
-
return { ok: false };
|
|
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);
|
|
679
|
-
try {
|
|
680
|
-
view.webContents.setZoomFactor(f);
|
|
681
|
-
return { ok: true, factor: f };
|
|
682
|
-
}
|
|
683
|
-
catch {
|
|
684
|
-
return { ok: false };
|
|
685
|
-
}
|
|
389
|
+
// ── THE BROWSER SLOT: THE FIRST INSTANCE OF `embedded-slot.ts` ─────────────────────────────
|
|
390
|
+
// Built ONCE here (module load), the way the bind call it replaces was. Everything the slot needs is
|
|
391
|
+
// either a function object this file owns (the window getter and the ONE load door above) or a policy
|
|
392
|
+
// answer only THIS view may use — and the two that must not be merged into the module are the popup
|
|
393
|
+
// decision and the recovery target, because they are what keeps this view a general browser while the
|
|
394
|
+
// DSH's is confined to its own loopback origin (see `embedded-slot.ts`'s header).
|
|
395
|
+
const browserSlot = (0, embedded_slot_1.embeddedSlot)({
|
|
396
|
+
// A GETTER, not the value: the window does not exist yet and is nulled when it closes.
|
|
397
|
+
win: () => win,
|
|
398
|
+
door: loadTarget,
|
|
399
|
+
// `target=_blank` through the ONE load door: its argument is the DECIDED target, and a refused scheme
|
|
400
|
+
// answers `undefined` — DROPPED rather than collapsed to `about:blank`, so an external link cannot
|
|
401
|
+
// erase the page the operator is reading.
|
|
402
|
+
popupTarget: (raw) => (0, summrise_shell_policy_1.embeddedPopupTarget)(loadTarget(raw)),
|
|
403
|
+
// The last DECIDED url, or the crate's blank fallback: recovering a crashed view must not fetch a
|
|
404
|
+
// third-party home page to do it.
|
|
405
|
+
recoverUrl: (lastDecided) => (0, summrise_shell_policy_1.embeddedRecoverUrl)(lastDecided),
|
|
406
|
+
// This state feeds the SPA's ADDRESS BAR, which must never go blank: a view that cannot answer reports
|
|
407
|
+
// the last url the door decided for it. (The DSH's record does the opposite — see its instance below.)
|
|
408
|
+
stateUrl: (live, lastDecided) => (0, summrise_shell_policy_1.shownUrl)(live, lastDecided),
|
|
409
|
+
goneChannel: "embedded-browser:gone",
|
|
410
|
+
// THE BROWSER-ONLY SURFACE: nav pushes, canBack/canFwd, and go/zoom. The DSH instance has none of it.
|
|
411
|
+
navChannel: "embedded-browser:nav",
|
|
686
412
|
});
|
|
413
|
+
// The browser-session windows' seam, bound ONCE here (module load) and never re-bound. Three things
|
|
414
|
+
// this file owns and the sessions do not: the ONE load door above (their every URL goes through it),
|
|
415
|
+
// the window icon the main window also uses, and the cap's number from the boot table. `main.ts`
|
|
416
|
+
// requiring the module and the module requiring `main.ts` would be a cycle, so they are lent instead,
|
|
417
|
+
// and `browser-sessions.ts` reads them through its own `deps()`.
|
|
418
|
+
(0, browser_sessions_1.browserSessionsBind)({ loadTarget, windowIcon, maxBrowserWindows: K.MAX_BROWSER_WINDOWS });
|
|
419
|
+
ipcHandle("browser-session:open", (url) => (0, browser_sessions_1.browserOpen)(url));
|
|
420
|
+
ipcHandle("browser-session:close", (id) => (0, browser_sessions_1.browserClose)(id));
|
|
421
|
+
ipcHandle("browser-session:list", () => (0, browser_sessions_1.browserList)());
|
|
422
|
+
// ── THE EMBEDDED VIEW'S IPC — THE REGISTRATIONS STAY HERE ──────────────────────────────────
|
|
423
|
+
// The slot itself (its state and its operations) is `embedded-slot.ts`. These lines do NOT go with it:
|
|
424
|
+
// this file holds the ONE `ipcMain.handle` door — `ipcHandle` applies the frame check — and
|
|
425
|
+
// `test/ipc-door.test.mjs` pins the channels registered behind that door against the ones `preload.ts`
|
|
426
|
+
// invokes. A handler registered in the module would be a second door.
|
|
427
|
+
ipcHandle("embedded-browser:recover", () => browserSlot.recover());
|
|
428
|
+
ipcHandle("embedded-browser:navigate", (url) => browserSlot.navigate(String(url || "")));
|
|
429
|
+
ipcHandle("embedded-browser:back", () => browserSlot.go(-1));
|
|
430
|
+
ipcHandle("embedded-browser:fwd", () => browserSlot.go(1));
|
|
431
|
+
ipcHandle("embedded-browser:reload", () => browserSlot.reload());
|
|
432
|
+
ipcHandle("embedded-browser:zoom", (factor) => browserSlot.zoom(factor));
|
|
687
433
|
ipcHandle("embedded-browser:place", (bounds) => {
|
|
688
|
-
|
|
434
|
+
browserSlot.place(bounds);
|
|
689
435
|
return { ok: true };
|
|
690
436
|
});
|
|
691
|
-
ipcHandle("embedded-browser:state", () =>
|
|
437
|
+
ipcHandle("embedded-browser:state", () => browserSlot.state());
|
|
692
438
|
// ── THE DSH VIEW: the harness's own UI, embedded in summrise ────────────────────────────────
|
|
693
439
|
//
|
|
694
440
|
// WHY LOOPBACK AND NOT THE PUBLIC HOSTNAME. The DSH runs on THIS machine (127.0.0.1:18081), and
|
|
@@ -701,9 +447,26 @@ ipcHandle("embedded-browser:state", () => embeddedState());
|
|
|
701
447
|
//
|
|
702
448
|
// A SECOND view, not a re-use of the browser one: the browser view is a single-tab internet
|
|
703
449
|
// browser an AI drives over CDP, and navigating it to the DSH would throw that session away.
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
450
|
+
//
|
|
451
|
+
// ── THE DSH SLOT IS THE SECOND INSTANCE OF THE SAME MODULE; THIS IS ITS HOST HALF ──────────
|
|
452
|
+
// The view's state and its operations are `embedded-slot.ts` now, built from the same factory as the
|
|
453
|
+
// browser's. What matters HERE is the three functions below it that DELIBERATELY DID NOT GO INTO IT,
|
|
454
|
+
// because they belong to the boot and the host rather than to the view:
|
|
455
|
+
// * `bootDshPort` — the boot's port pin, the twin of `bootAgentPort` below: called once, from
|
|
456
|
+
// `app.whenReady()`, as the argument to `setDshPort`, which feeds the url policy's port state
|
|
457
|
+
// that this file owns. No operation of the view calls it.
|
|
458
|
+
// * `dshTargetFor` — the ONE load door for that view, and a door is the HOST's: this file owns
|
|
459
|
+
// the load door of every view it lends one to (`loadTarget` above is the same shape for the
|
|
460
|
+
// other two modules), and it is where this file evaluates the url-policy predicate
|
|
461
|
+
// `isDshUrl` — the rule the import block above states for both crates. It is LENT to the slot in
|
|
462
|
+
// the `embeddedSlot` call below, never re-declared there.
|
|
463
|
+
// * `loadHarnessDoors` — the boot's ONE read of the agent's harness table: it MUTATES the url
|
|
464
|
+
// policy's own port list (`addDshPort`), fetches with the device token (`authHeaders`) and is
|
|
465
|
+
// bounded by the boot table's `HARNESS_FETCH_MS`. The view never calls it, so lending this
|
|
466
|
+
// module a credential and a boot number for a caller it does not have would be dependency
|
|
467
|
+
// surface bought for nothing.
|
|
468
|
+
// `WebContentsView` left this file's electron import with the view: nothing here names the type
|
|
469
|
+
// any more, and a surface no reader can act on is the rule the child_process import above cites.
|
|
707
470
|
/** The DSH's port. Env first (a deployment may move it), else the component's default — the range test
|
|
708
471
|
* and the fallback are the policy crate's, the env read is this file's. */
|
|
709
472
|
function bootDshPort() {
|
|
@@ -749,133 +512,50 @@ function loadHarnessDoors() {
|
|
|
749
512
|
})
|
|
750
513
|
.finally(() => clearTimeout(timer));
|
|
751
514
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
catch { /* already gone */ }
|
|
781
|
-
dshVisible = false;
|
|
782
|
-
// The SAME signal the browser view sends, for the same reason (round-256 there): without it
|
|
783
|
-
// the pane shows "starting…" forever over a view that will never paint, and the operator has
|
|
784
|
-
// no way to tell a slow load from a dead renderer.
|
|
785
|
-
if (win && !win.isDestroyed()) {
|
|
786
|
-
win.webContents.send("embedded-dsh:gone", { reason: details.reason, exitCode: details.exitCode });
|
|
787
|
-
}
|
|
788
|
-
});
|
|
789
|
-
dshView = view;
|
|
790
|
-
if (win)
|
|
791
|
-
win.contentView.addChildView(view);
|
|
792
|
-
return view;
|
|
793
|
-
}
|
|
794
|
-
function dshNavigate(url) {
|
|
795
|
-
const target = dshTargetFor(url);
|
|
796
|
-
const view = dshViewEnsure();
|
|
797
|
-
view.webContents.loadURL(target).catch(() => { });
|
|
798
|
-
return { ok: true, url: target };
|
|
799
|
-
}
|
|
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. */
|
|
515
|
+
// THE DSH INSTANCE, built ONCE here (module load) from the SAME factory as the browser slot. The window
|
|
516
|
+
// is a getter because it does not exist yet and is nulled when it closes; `door` is the load door above;
|
|
517
|
+
// and the DSH's own answers travel as FUNCTIONS rather than as values for the reason the crate's docs
|
|
518
|
+
// give — "the BASE arrives from the host, which is the only thing that can read the configured DSH
|
|
519
|
+
// port" — and because `setDshPort` runs at `whenReady`, so a snapshot taken here would pin the port list
|
|
520
|
+
// before the agent's harness table has even been read.
|
|
521
|
+
//
|
|
522
|
+
// THE LIFECYCLE IS THE CALLER'S, NOT A PARAMETER: this slot is created LAZILY (nothing calls `ensure()`
|
|
523
|
+
// until the pane's `open()` navigates), where the browser's is created eagerly at startup. `ensure` is on
|
|
524
|
+
// the slot's surface for both instances; only the browser's caller asks for it early.
|
|
525
|
+
const dshSlot = (0, embedded_slot_1.embeddedSlot)({
|
|
526
|
+
win: () => win,
|
|
527
|
+
door: dshTargetFor,
|
|
528
|
+
// The DSH's own popup decision: over its own door, and only the harness's origin is admitted — an
|
|
529
|
+
// external `target=_blank` link is dropped rather than navigating this pane away from the harness.
|
|
530
|
+
popupTarget: (raw) => (0, summrise_shell_policy_1.dshPopupTarget)(raw, (0, summrise_url_policy_1.isDshUrl)(raw)),
|
|
531
|
+
// Always the harness home: a crash recovery that lands somewhere other than the harness is a view the
|
|
532
|
+
// operator did not ask for.
|
|
533
|
+
recoverUrl: () => (0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)()),
|
|
534
|
+
// This record describes the LIVE view and nothing else — a dead webContents reports blank rather than
|
|
535
|
+
// its last url (the browser slot's address bar is the opposite case; see its instance above).
|
|
536
|
+
stateUrl: (live) => (0, summrise_shell_policy_1.shownUrl)(live, ""),
|
|
537
|
+
goneChannel: "embedded-dsh:gone",
|
|
538
|
+
// NO navChannel: the DSH pane has no address bar and no back/fwd buttons.
|
|
539
|
+
});
|
|
540
|
+
/** Open (or re-focus) the DSH at its configured address. Idempotent — and the `open` OPERATION stays in
|
|
541
|
+
* this file because the lifecycle is the caller's: this is the call that CREATES the DSH's view (the
|
|
542
|
+
* slot builds it lazily inside `navigate`), and navigates it home through the ONE door above. */
|
|
805
543
|
function dshOpen() {
|
|
806
|
-
return
|
|
807
|
-
}
|
|
808
|
-
/** Place the view over the SPA's slot. Empty bounds hide it (the same contract as the browser). */
|
|
809
|
-
function dshPlace(bounds) {
|
|
810
|
-
const view = dshViewEnsure();
|
|
811
|
-
if (!win)
|
|
812
|
-
return;
|
|
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)) {
|
|
815
|
-
view.setVisible(false);
|
|
816
|
-
dshVisible = false;
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
|
-
dshBounds = bounds;
|
|
820
|
-
view.setBounds(bounds);
|
|
821
|
-
view.setVisible(true);
|
|
822
|
-
dshVisible = true;
|
|
823
|
-
win.contentView.addChildView(view);
|
|
824
|
-
}
|
|
825
|
-
function dshReload() {
|
|
826
|
-
const view = dshView;
|
|
827
|
-
if (!view || view.webContents.isDestroyed())
|
|
828
|
-
return { ok: false };
|
|
829
|
-
try {
|
|
830
|
-
view.webContents.reload();
|
|
831
|
-
return { ok: true };
|
|
832
|
-
}
|
|
833
|
-
catch {
|
|
834
|
-
return { ok: false };
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
/** Recover after a renderer crash: a dead webContents never recovers, so re-create the view. */
|
|
838
|
-
function dshRecover() {
|
|
839
|
-
try {
|
|
840
|
-
if (dshView && !dshView.webContents.isDestroyed())
|
|
841
|
-
dshView.webContents.close({ waitForBeforeUnload: false });
|
|
842
|
-
if (dshView) {
|
|
843
|
-
try {
|
|
844
|
-
win?.contentView.removeChildView(dshView);
|
|
845
|
-
}
|
|
846
|
-
catch { /* already gone */ }
|
|
847
|
-
dshView = null;
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
catch { /* fall through to ensure() */ }
|
|
851
|
-
const view = dshViewEnsure();
|
|
852
|
-
if (!view || view.webContents.isDestroyed())
|
|
853
|
-
return { ok: false };
|
|
854
|
-
view.webContents.loadURL((0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)())).catch(() => { });
|
|
855
|
-
if (dshVisible && win && dshBounds) {
|
|
856
|
-
view.setBounds(dshBounds);
|
|
857
|
-
view.setVisible(true);
|
|
858
|
-
win.contentView.addChildView(view);
|
|
859
|
-
}
|
|
860
|
-
return { ok: true };
|
|
861
|
-
}
|
|
862
|
-
function dshState() {
|
|
863
|
-
const view = dshView;
|
|
864
|
-
const wc = view && !view.webContents.isDestroyed() ? view.webContents : null;
|
|
865
|
-
return { ok: true, url: wc ? wc.getURL() : "", title: wc ? wc.getTitle() : "", visible: dshVisible && !!wc };
|
|
544
|
+
return dshSlot.navigate((0, summrise_shell_policy_1.dshHome)((0, summrise_url_policy_1.dshBase)()));
|
|
866
545
|
}
|
|
867
546
|
ipcHandle("embedded-dsh:open", () => dshOpen());
|
|
868
547
|
ipcHandle("embedded-dsh:place", (bounds) => {
|
|
869
|
-
|
|
548
|
+
dshSlot.place(bounds);
|
|
870
549
|
return { ok: true };
|
|
871
550
|
});
|
|
872
|
-
ipcHandle("embedded-dsh:state", () =>
|
|
873
|
-
// SELECT A HOST: `
|
|
874
|
-
// only reach a door the agent configured — and it answers with the address it actually
|
|
875
|
-
// refusal is `about:blank`. The pane shows that rather than pretending the switch
|
|
876
|
-
|
|
877
|
-
ipcHandle("embedded-dsh:
|
|
878
|
-
ipcHandle("embedded-dsh:
|
|
551
|
+
ipcHandle("embedded-dsh:state", () => dshSlot.state());
|
|
552
|
+
// SELECT A HOST: the slot's `navigate` already refuses anything `isDshUrl` does not admit, so this
|
|
553
|
+
// channel can only reach a door the agent configured — and it answers with the address it actually
|
|
554
|
+
// loaded, which for a refusal is `about:blank`. The pane shows that rather than pretending the switch
|
|
555
|
+
// worked.
|
|
556
|
+
ipcHandle("embedded-dsh:go", (url) => dshSlot.navigate(String(url)));
|
|
557
|
+
ipcHandle("embedded-dsh:reload", () => dshSlot.reload());
|
|
558
|
+
ipcHandle("embedded-dsh:recover", () => dshSlot.recover());
|
|
879
559
|
// Desktop-app settings (auto-launch) — the Settings page toggles this. We
|
|
880
560
|
// manage a per-user scheduled task ("SummriseDesktop", onlogon) instead of
|
|
881
561
|
// Electron's setLoginItemSettings: in dev mode (electron .) the login-item
|
|
@@ -1070,9 +750,9 @@ const httpServer = http.createServer((req, res) => {
|
|
|
1070
750
|
// guard (a POST reaches the same reader), while `/open`, `/close` and `/start-agent` require POST and
|
|
1071
751
|
// a GET to one of them falls through to the 404.
|
|
1072
752
|
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());
|
|
753
|
+
case "browser-open": return send((0, browser_sessions_1.browserOpen)((0, summrise_shell_policy_1.controlQuery)(req.url, "url") || K.ABOUT_BLANK));
|
|
754
|
+
case "browser-close": return send((0, browser_sessions_1.browserClose)((0, summrise_shell_policy_1.controlQuery)(req.url, "id") || ""));
|
|
755
|
+
case "browser-list": return send((0, browser_sessions_1.browserList)());
|
|
1076
756
|
case "start-agent": return (async () => send(await startAgentTask()))();
|
|
1077
757
|
case "agent-status": return (async () => send({ ok: true, running: await agentResponds(CONTROL_PROBE_MS) }))();
|
|
1078
758
|
case "icon-status": return send({ ok: true, icons: iconReport,
|
|
@@ -1227,7 +907,7 @@ if (gotTheLock) {
|
|
|
1227
907
|
// review #7: a target=_blank from the SPA must NOT get a preload-bearing
|
|
1228
908
|
// window; route it to a sandboxed browser session instead.
|
|
1229
909
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
1230
|
-
browserOpen(url);
|
|
910
|
+
(0, browser_sessions_1.browserOpen)(url);
|
|
1231
911
|
return { action: "deny" };
|
|
1232
912
|
});
|
|
1233
913
|
// stage-n: load /desktop/ only when the agent is actually listening.
|
|
@@ -1447,14 +1127,17 @@ if (gotTheLock) {
|
|
|
1447
1127
|
// attached playwright/AI ALWAYS has its own dedicated page to drive —
|
|
1448
1128
|
// without it, playwright grabbed the MAIN window (now tripwired) or the
|
|
1449
1129
|
// user had to open the Browser page first. The view is hidden until the
|
|
1450
|
-
// SPA's Browser page reports bounds (
|
|
1130
|
+
// SPA's Browser page reports bounds (the slot's place() shows it).
|
|
1451
1131
|
// NO EAGER NAVIGATION: this used to loadURL("https://www.bing.com"), i.e. a
|
|
1452
1132
|
// request to a third party on EVERY launch before anyone asked for a page.
|
|
1453
1133
|
// The view is still created — about:blank is a first-class CDP target (the
|
|
1454
1134
|
// reason it exists here is that a target be available, not that a page be
|
|
1455
1135
|
// loaded), and the user/AI navigates it next (loadTarget is the one door).
|
|
1136
|
+
// THIS CALL IS THE BROWSER SLOT'S LIFECYCLE AND IT LIVES HERE ON PURPOSE: the browser's view is
|
|
1137
|
+
// created eagerly, the DSH's is created lazily by its pane's open() — a difference between two
|
|
1138
|
+
// CALLERS, so it is these lines rather than a parameter of the shared slot.
|
|
1456
1139
|
try {
|
|
1457
|
-
|
|
1140
|
+
browserSlot.ensure();
|
|
1458
1141
|
}
|
|
1459
1142
|
catch { /* non-fatal */ }
|
|
1460
1143
|
console.log(`[summrise] CDP endpoint: ${(0, summrise_shell_policy_1.cdpEndpoint)(K.CDP_PORT)} (playwright connectOverCDP)`);
|
|
@@ -1609,11 +1292,7 @@ electron_1.app.on("before-quit", () => {
|
|
|
1609
1292
|
electron_1.app.isQuitting = true;
|
|
1610
1293
|
// stage-n: close every browser-session window explicitly — app.quit()
|
|
1611
1294
|
// tears down the main window but independent BrowserWindows with pending
|
|
1612
|
-
// beforeunload handlers can stall or leak on exit.
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
bw.destroy();
|
|
1616
|
-
browserSessions.delete(id);
|
|
1617
|
-
browserTargets.delete(id);
|
|
1618
|
-
}
|
|
1295
|
+
// beforeunload handlers can stall or leak on exit. The sweep itself lives with the
|
|
1296
|
+
// table it walks (`browser-sessions.ts`), which is why this is a call and not a loop.
|
|
1297
|
+
(0, browser_sessions_1.browserCloseAll)();
|
|
1619
1298
|
});
|
|
Binary file
|
package/summrise-launch.exe
CHANGED
|
Binary file
|