sliccy 6.39.1 → 6.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/node-server/electron-controller.d.ts +82 -0
- package/dist/node-server/electron-controller.js +172 -5
- package/dist/node-server/electron-federated-cdp.d.ts +73 -0
- package/dist/node-server/electron-federated-cdp.js +166 -0
- package/dist/node-server/electron-runtime.d.ts +4 -0
- package/dist/node-server/electron-runtime.js +3 -0
- package/dist/node-server/electron-tray-follower.d.ts +144 -0
- package/dist/node-server/electron-tray-follower.js +463 -0
- package/dist/node-server/index.js +54 -2
- package/dist/ui/assets/account-store-BOj83xge.js +100 -0
- package/dist/ui/assets/{account-store-8zHKHzZf.js → account-store-DZhNh1lP.js} +1 -1
- package/dist/ui/assets/{adobe-CD47VxQ1.js → adobe-B44VQ6eM.js} +1 -1
- package/dist/ui/assets/{adobe-C__bPqu-.js → adobe-CkY4aeYQ.js} +1 -1
- package/dist/ui/assets/{apply-layout-DaQTyGVJ.js → apply-layout-C5EFkVOb.js} +1 -1
- package/dist/ui/assets/{apps-BSqeYsFl.js → apps-D6aFuZ__.js} +1 -1
- package/dist/ui/assets/{azure-openai-BfVVshcW.js → azure-openai-B28v9Vnf.js} +1 -1
- package/dist/ui/assets/{azure-openai-DAJX2mxQ.js → azure-openai-CCkWzmoI.js} +1 -1
- package/dist/ui/assets/{backend-local-Btkv66jT.js → backend-local-D7Gqh_i1.js} +1 -1
- package/dist/ui/assets/{backend-local-D4WZgy4J.js → backend-local-rrSF0A3j.js} +1 -1
- package/dist/ui/assets/{bsh-watchdog-BWoQ4vxy.js → bsh-watchdog-CpQHnQNg.js} +1 -1
- package/dist/ui/assets/{cdp-CUhn-e9o.js → cdp-DxpD3C8-.js} +1 -1
- package/dist/ui/assets/{cerebras-DH7EiStU.js → cerebras-5iuoNSQP.js} +1 -1
- package/dist/ui/assets/{cerebras-kGQ6eAhx.js → cerebras-c3TJ1kI0.js} +1 -1
- package/dist/ui/assets/{connect-surface-BWUXCSRt.js → connect-surface-gzLUHoaC.js} +1 -1
- package/dist/ui/assets/{dip-DReIJmS8.js → dip-VzeVAaf7.js} +2 -2
- package/dist/ui/assets/{fs-C0NPGido.js → fs-C2lY2r22.js} +1 -1
- package/dist/ui/assets/{fs-Cequurmj.js → fs-Dmq-Uipw.js} +2 -2
- package/dist/ui/assets/{github-5sgbo7jZ.js → github-CVDuNHn7.js} +1 -1
- package/dist/ui/assets/github-D6Uxzk_I.js +2 -0
- package/dist/ui/assets/{github-copilot-kBcOKHxp.js → github-copilot-CCYIsLiU.js} +1 -1
- package/dist/ui/assets/{github-copilot-Bo9JF5a-.js → github-copilot-EOD2wLtx.js} +1 -1
- package/dist/ui/assets/{github-install-CvpnvL5c.js → github-install-D1MDzwb-.js} +1 -1
- package/dist/ui/assets/{github-zip-BWQMOpzT.js → github-zip-JZM1fZvs.js} +1 -1
- package/dist/ui/assets/{kernel-worker-C-AHxfuN.js → kernel-worker-3-c1T_Ut.js} +635 -632
- package/dist/ui/assets/lick-ws-bridge-CFFtf-gn.js +1 -0
- package/dist/ui/assets/lick-ws-bridge-DeUbk3le.js +1 -0
- package/dist/ui/assets/{local-llm-C7Qt3nVY.js → local-llm-C817P7no.js} +1 -1
- package/dist/ui/assets/{local-llm-PR0ztiYK.js → local-llm-DleKpjrX.js} +1 -1
- package/dist/ui/assets/logger-BoSvu4mW.js +1 -0
- package/dist/ui/assets/logger-DQgFbfwx.js +1 -0
- package/dist/ui/assets/{main-CjKZsW9X.js → main-D02Bshqy.js} +3 -3
- package/dist/ui/assets/{main-cherry-B3C0ufyv.js → main-cherry-CIOlblhu.js} +1 -1
- package/dist/ui/assets/{mount-DrKRVo4a.js → mount-C-4-Mnur.js} +1 -1
- package/dist/ui/assets/{mount-BS2IDRu-.js → mount-Cws5YfoU.js} +2 -2
- package/dist/ui/assets/mount-picker-popup-Bh-wMX4z.js +1 -0
- package/dist/ui/assets/{mount-recovery-Cln0Kwvu.js → mount-recovery-DkHAVPiu.js} +1 -1
- package/dist/ui/assets/new-session-d3K2nWav.js +2 -0
- package/dist/ui/assets/oauth-bootstrap-MRD7a4SW.js +2 -0
- package/dist/ui/assets/{openai-codex-DBXsg0MM.js → openai-codex-_I5Lsm9r.js} +1 -1
- package/dist/ui/assets/{openai-codex-DcvV7I52.js → openai-codex-k-N5xxJt.js} +1 -1
- package/dist/ui/assets/{openrouter-D52jlKXD.js → openrouter-1FGA9TBR.js} +1 -1
- package/dist/ui/assets/{openrouter-DBBBsBmt.js → openrouter-8zyBa8nl.js} +1 -1
- package/dist/ui/assets/{openrouter-oauth-D35pGOT5.js → openrouter-oauth-A0KBFgc_.js} +1 -1
- package/dist/ui/assets/{openrouter-oauth-jbgq6X1-.js → openrouter-oauth-BMleur3U.js} +1 -1
- package/dist/ui/assets/{panel-rpc-handlers-gpN6c-a2.js → panel-rpc-handlers-DzYOsxK_.js} +2 -2
- package/dist/ui/assets/{panelize-shell-DeP-Mot2.js → panelize-shell-DMbSe6Cp.js} +1 -1
- package/dist/ui/assets/{picker-approval-CdWaB1mR.js → picker-approval-CXL7T-sh.js} +1 -1
- package/dist/ui/assets/{provider-BUmrpCDs.js → provider-B0dbza19.js} +1 -1
- package/dist/ui/assets/{provider-wlWK7FGl.js → provider-ZRVkoUAT.js} +2 -2
- package/dist/ui/assets/{provider-settings-E2_3_Nn0.js → provider-settings-B76dsYi_.js} +1 -1
- package/dist/ui/assets/{provider-settings-DEeVOQVO.js → provider-settings-YcRcX51v.js} +1 -1
- package/dist/ui/assets/provider-store-access-CgBHXCh_.js +1 -0
- package/dist/ui/assets/provider-store-access-jt-VuLaS.js +2 -0
- package/dist/ui/assets/providers-Byu4I4zs.js +2 -0
- package/dist/ui/assets/{providers-D91y678Z.js → providers-CMFxANiX.js} +1 -1
- package/dist/ui/assets/{quick-llm-TL2ngvw6.js → quick-llm-DB8hnmed.js} +1 -1
- package/dist/ui/assets/{recovery-screen-B2hwv_k3.js → recovery-screen--1O8eU7g.js} +1 -1
- package/dist/ui/assets/{redirect-uri-COdP9xBw.js → redirect-uri-DHGBfMTX.js} +1 -1
- package/dist/ui/assets/{remote-terminal-view-2Wk02uVb.js → remote-terminal-view-Q8aeCLeG.js} +1 -1
- package/dist/ui/assets/{session-freezer-lTtNV89v.js → session-freezer-BvaAG9k9.js} +7 -7
- package/dist/ui/assets/{setup-feature-flags-remote-BdWq_ZXF.js → setup-feature-flags-remote-BRjpfk0R.js} +1 -1
- package/dist/ui/assets/{setup-standalone-prelude-U4VmZy8w.js → setup-standalone-prelude-D0i3nMAq.js} +2 -2
- package/dist/ui/assets/setup-sudo-B__wUcBH.js +2 -0
- package/dist/ui/assets/{skills-Bh7wIvj-.js → skills-D2uYHYBK.js} +1 -1
- package/dist/ui/assets/{src-WmaDIMoL.js → src-iJcLCxRd.js} +103 -103
- package/dist/ui/assets/{store-B0ZSErNM.js → store-BFReevIV.js} +1 -1
- package/dist/ui/assets/{store-Cbq0lKAx.js → store-Bxt8NcqL.js} +1 -1
- package/dist/ui/assets/{sudo-DPfno2Fe.js → sudo-BJ5Pw_5M.js} +1 -1
- package/dist/ui/assets/{tab-teleport-CLc1Tuqi.js → tab-teleport-Dhh60nH7.js} +1 -1
- package/dist/ui/assets/{teleport-storage-Beud1T6_.js → teleport-storage-CwYntbOB.js} +1 -1
- package/dist/ui/assets/tool-ui-C-E235kX.js +1 -0
- package/dist/ui/assets/tool-ui-Dq9m-pLC.js +1 -0
- package/dist/ui/assets/{upgrade-detection-DLES83VK.js → upgrade-detection-CsUicV6H.js} +1 -1
- package/dist/ui/assets/{wc-attach-BRFtXk_B.js → wc-attach-ZYwsizqf.js} +2 -2
- package/dist/ui/assets/{wc-browser-BTGpaihO.js → wc-browser-DdPBkgaJ.js} +1 -1
- package/dist/ui/assets/{wc-extension-Bh63DngU.js → wc-extension-yYG4eEP9.js} +2 -2
- package/dist/ui/assets/wc-follower-DUFNQfPQ.js +17 -0
- package/dist/ui/assets/{wc-live-pHaHvtg9.js → wc-live-2R8dyI8p.js} +3 -3
- package/dist/ui/assets/wc-nav-BYI4hReW.js +2 -0
- package/dist/ui/assets/{wc-onboarding-C5rtuepw.js → wc-onboarding-jNrGVQ4A.js} +2 -2
- package/dist/ui/assets/{wc-permissions-iqdYkwde.js → wc-permissions-Dw-_JpYr.js} +3 -3
- package/dist/ui/assets/wc-permissions-providers-BOgM_jqs.js +2 -0
- package/dist/ui/assets/{wc-placeholder-C0YTlp-D.js → wc-placeholder-BVsXZIwG.js} +2 -2
- package/dist/ui/assets/{wc-settings-Bzucq5Ey.js → wc-settings-CEbEFdqB.js} +2 -2
- package/dist/ui/assets/{wc-shell-C4nE6X2z.js → wc-shell-DQFNkoU5.js} +3 -3
- package/dist/ui/assets/{wc-sprinkles-CiwBe9NF.js → wc-sprinkles-BDlwD_fb.js} +2 -2
- package/dist/ui/assets/wc-tray--MLF73UI.js +2 -0
- package/dist/ui/assets/wc-tray-scoops-T7dGFJhf.js +5 -0
- package/dist/ui/assets/{xai-grok-CZvU2oSp.js → xai-grok-BWbFBwcw.js} +1 -1
- package/dist/ui/assets/{xai-grok-C1YVEpmg.js → xai-grok-DpQmep92.js} +1 -1
- package/dist/ui/electron-overlay-entry.js +10 -10
- package/dist/ui/index.html +8 -7
- package/dist/ui/packages/webapp/index.html +8 -7
- package/package.json +2 -1
- package/dist/ui/assets/account-store-Cxz3loXT.js +0 -100
- package/dist/ui/assets/github-Zugg5Qj-.js +0 -2
- package/dist/ui/assets/lick-ws-bridge-Cpex5Pe9.js +0 -1
- package/dist/ui/assets/lick-ws-bridge-D_e6wRPA.js +0 -1
- package/dist/ui/assets/mount-picker-popup-CSSC05JS.js +0 -1
- package/dist/ui/assets/new-session-BUyVHhRL.js +0 -2
- package/dist/ui/assets/oauth-bootstrap-ChJOKAmZ.js +0 -2
- package/dist/ui/assets/provider-store-access-BNb6Ckg9.js +0 -2
- package/dist/ui/assets/provider-store-access-BpxhTrT1.js +0 -1
- package/dist/ui/assets/providers-CBlACIFz.js +0 -2
- package/dist/ui/assets/setup-sudo-BIhsJd5L.js +0 -2
- package/dist/ui/assets/tool-ui-DfXu6quJ.js +0 -1
- package/dist/ui/assets/tool-ui-t6ocn6AG.js +0 -1
- package/dist/ui/assets/wc-follower-_jlSzC2-.js +0 -17
- package/dist/ui/assets/wc-nav-nGeFgw0Y.js +0 -2
- package/dist/ui/assets/wc-permissions-providers-DpoCXfAQ.js +0 -2
- package/dist/ui/assets/wc-tray-CAvYhNoy.js +0 -2
- package/dist/ui/assets/wc-tray-scoops-Bpcyy0qK.js +0 -5
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ If this scares, confuses, or excites you, keep reading.
|
|
|
11
11
|
|
|
12
12
|
# slicc — Self-Licking Ice Cream Cone
|
|
13
13
|
|
|
14
|
-
[](https://github.com/ai-ecoverse/vibe-coded-badge-action)
|
|
15
15
|
|
|
16
16
|
[](https://www.npmjs.com/package/sliccy)
|
|
17
17
|
|
|
@@ -94,7 +94,34 @@ export declare function resolveFetchProxyOrigin(targetUrl: string, servePort: nu
|
|
|
94
94
|
export interface ThinBootstrapSet {
|
|
95
95
|
leader: string;
|
|
96
96
|
follower: string;
|
|
97
|
+
/**
|
|
98
|
+
* Status-only overlay bootstrap: injects the launcher with NO app-url (no
|
|
99
|
+
* iframe) and a message explaining the app blocks the embedded panel. Used
|
|
100
|
+
* for egress-blocked apps (e.g. Signal) so the user sees the launcher + a
|
|
101
|
+
* clear note instead of a silent blank panel.
|
|
102
|
+
*/
|
|
103
|
+
status: string;
|
|
97
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Empty-viewport message shown by the status-only overlay for an app that
|
|
107
|
+
* denies the overlay iframe's network egress. Kept in sync with swift-server's
|
|
108
|
+
* `overlayStatusMessageEgressBlocked`.
|
|
109
|
+
*/
|
|
110
|
+
export declare const OVERLAY_STATUS_MESSAGE_EGRESS_BLOCKED = "SLICC is attached to this app, but it blocks embedded panels. Drive it from the SLICC leader window.";
|
|
111
|
+
/**
|
|
112
|
+
* CDP `Network.loadingFailed` `errorText` values that mean the *app itself*
|
|
113
|
+
* denied the overlay's document request at the network layer — not a CSP block.
|
|
114
|
+
* Signal (and other locked-down Electron apps) proxy all renderer network
|
|
115
|
+
* through their main process and deny external requests with
|
|
116
|
+
* `net::ERR_ACCESS_DENIED`, BENEATH the layer where `Page.setBypassCSP` or the
|
|
117
|
+
* CDP Fetch proxy operate — so the reload/Fetch escalation cannot help and must
|
|
118
|
+
* be skipped. Unlike a CSP block, the thrown probe (`OVERLAY_LOADED_PROBE_EXPRESSION`)
|
|
119
|
+
* can't distinguish this from a successful cross-origin load (both throw), so we
|
|
120
|
+
* detect it authoritatively from the failed document request instead.
|
|
121
|
+
*/
|
|
122
|
+
export declare const OVERLAY_EGRESS_BLOCK_ERROR_TEXTS: readonly string[];
|
|
123
|
+
/** True when `errorText` is one of {@link OVERLAY_EGRESS_BLOCK_ERROR_TEXTS}. */
|
|
124
|
+
export declare function isOverlayEgressBlockError(errorText: string | undefined): boolean;
|
|
98
125
|
/**
|
|
99
126
|
* JS probe that reports whether the overlay iframe actually loaded. Walks the
|
|
100
127
|
* `<slicc-launcher>` host's (open) shadow root to find the iframe
|
|
@@ -107,6 +134,13 @@ export interface ThinBootstrapSet {
|
|
|
107
134
|
* so the overlay did not load and the setBypassCSP escalation must fire.
|
|
108
135
|
* Returns `'ok'` only from the catch; otherwise `'no-host' / 'no-iframe' /
|
|
109
136
|
* 'no-src' / 'blank:<href>'`.
|
|
137
|
+
*
|
|
138
|
+
* CAVEAT: a *network-layer* block (the app denying the overlay's document
|
|
139
|
+
* request with `net::ERR_ACCESS_DENIED`) swaps to a CROSS-origin `chrome-error`
|
|
140
|
+
* page whose href access ALSO throws — indistinguishable here from a real load.
|
|
141
|
+
* That case is detected authoritatively from `Network.loadingFailed`
|
|
142
|
+
* (see {@link isOverlayEgressBlockError}); this probe's `'ok'` is overridden by
|
|
143
|
+
* the connection's `egressBlocked` flag so it is never recorded as loaded.
|
|
110
144
|
*/
|
|
111
145
|
export declare const OVERLAY_LOADED_PROBE_EXPRESSION = "(function() {\n var host = document.getElementById('slicc-electron-overlay-root');\n if (!host || !host.shadowRoot) return 'no-host';\n var iframe = host.shadowRoot.querySelector('iframe');\n if (!iframe) return 'no-iframe';\n if (!iframe.src) return 'no-src';\n try {\n // Thin-bridge overlay is ALWAYS cross-origin (hosted webapp) vs the app\n // document. A committed cross-origin navigation makes this access THROW.\n // Any READABLE href means the cross-origin nav did NOT commit \u2014 still\n // about:blank, or swapped to chrome-error://chromewebdata/ by a CSP block \u2014\n // so the overlay did NOT load and the setBypassCSP escalation must fire.\n var href = iframe.contentWindow && iframe.contentWindow.location ? iframe.contentWindow.location.href : '';\n return 'blank:' + href;\n } catch (e) {\n return 'ok';\n }\n })()";
|
|
112
146
|
/**
|
|
@@ -130,12 +164,32 @@ export declare class ElectronOverlayInjector {
|
|
|
130
164
|
private readonly presenceCheckIntervalMs;
|
|
131
165
|
private readonly connections;
|
|
132
166
|
private readonly cspBypassedTargets;
|
|
167
|
+
/**
|
|
168
|
+
* Per-process bridge token, present in every overlay app URL. Used to
|
|
169
|
+
* correlate a `Network.loadingFailed` back to OUR overlay iframe's document
|
|
170
|
+
* request (vs the app's own frames) when detecting an egress block.
|
|
171
|
+
*/
|
|
172
|
+
private readonly bridgeToken;
|
|
173
|
+
/**
|
|
174
|
+
* Targets whose overlay iframe was denied at the network layer by the app
|
|
175
|
+
* itself (e.g. Signal → `net::ERR_ACCESS_DENIED`). The reload/Fetch-proxy
|
|
176
|
+
* escalation cannot rescue these, so once a target is here we stop escalating
|
|
177
|
+
* and never record it as CSP-bypassed (which would falsely read as loaded).
|
|
178
|
+
*/
|
|
179
|
+
private readonly egressBlockedTargets;
|
|
133
180
|
/**
|
|
134
181
|
* URL of the target currently elected as the pinned leader. Cleared by
|
|
135
182
|
* `syncTargets` when that target disappears so the next injection
|
|
136
183
|
* re-elects a fresh leader.
|
|
137
184
|
*/
|
|
138
185
|
private leaderTargetUrl;
|
|
186
|
+
/**
|
|
187
|
+
* Fired ONCE when the app is first detected to block renderer egress, so the
|
|
188
|
+
* runtime can start the headless CDP-over-CDP follower (`ElectronTrayFollower`)
|
|
189
|
+
* for this app. The hosted overlay can never load in such apps, so exposing
|
|
190
|
+
* their CDP to the tray leader is the only way to drive them.
|
|
191
|
+
*/
|
|
192
|
+
private readonly onEgressBlocked?;
|
|
139
193
|
private syncTimer;
|
|
140
194
|
private syncing;
|
|
141
195
|
private constructor();
|
|
@@ -150,6 +204,8 @@ export declare class ElectronOverlayInjector {
|
|
|
150
204
|
* bundled overlay path was retired.
|
|
151
205
|
*/
|
|
152
206
|
thinBridge: ThinBridgeConfig;
|
|
207
|
+
/** Start the headless CDP-over-CDP follower when egress-block is detected. */
|
|
208
|
+
onEgressBlocked?: (targetUrl: string) => void;
|
|
153
209
|
}): Promise<ElectronOverlayInjector>;
|
|
154
210
|
/**
|
|
155
211
|
* Test-only factory: skips bundle loading and lets tests drive the per-target
|
|
@@ -160,6 +216,8 @@ export declare class ElectronOverlayInjector {
|
|
|
160
216
|
cdpPort?: number;
|
|
161
217
|
servePort: number;
|
|
162
218
|
thinBootstraps?: ThinBootstrapSet;
|
|
219
|
+
bridgeToken?: string;
|
|
220
|
+
onEgressBlocked?: (targetUrl: string) => void;
|
|
163
221
|
probeDelayMs?: number;
|
|
164
222
|
presenceCheckIntervalMs?: number;
|
|
165
223
|
}): ElectronOverlayInjector;
|
|
@@ -173,6 +231,10 @@ export declare class ElectronOverlayInjector {
|
|
|
173
231
|
_testingSeedBypassedTarget(url: string): void;
|
|
174
232
|
/** Test-only: snapshot the per-target "already bypassed" guard set. */
|
|
175
233
|
_testingBypassedTargets(): ReadonlySet<string>;
|
|
234
|
+
/** Test-only: snapshot the set of targets marked egress-blocked. */
|
|
235
|
+
_testingEgressBlockedTargets(): ReadonlySet<string>;
|
|
236
|
+
/** Test-only: seed a target as already egress-blocked. */
|
|
237
|
+
_testingSeedEgressBlockedTarget(url: string): void;
|
|
176
238
|
/** Test-only: drive a single `syncTargets` pass without `start()`'s interval. */
|
|
177
239
|
_testingSyncTargets(): Promise<void>;
|
|
178
240
|
/** Test-only: close any sockets opened by `_testingConnectToTarget`. */
|
|
@@ -262,5 +324,25 @@ export declare class ElectronOverlayInjector {
|
|
|
262
324
|
* is no CDP reply for fulfill, and the response body is the document body.
|
|
263
325
|
*/
|
|
264
326
|
private handleFetchRequestPaused;
|
|
327
|
+
/**
|
|
328
|
+
* Inspect a `Network.*` CDP event for the egress-block signal: track OUR
|
|
329
|
+
* overlay iframe's top-level Document request (matched by the per-process
|
|
330
|
+
* bridge token in its URL, so the app's own frames are ignored) and, when the
|
|
331
|
+
* app denies it at the network layer ({@link isOverlayEgressBlockError}), set
|
|
332
|
+
* `state.egressBlocked` and record the target. `Page.setBypassCSP` / the Fetch
|
|
333
|
+
* proxy operate above the layer that denies these, so escalation cannot help —
|
|
334
|
+
* the probe/escalation paths read `state.egressBlocked` to bail out instead of
|
|
335
|
+
* reload-churning and instead of falsely recording the target as loaded.
|
|
336
|
+
*/
|
|
337
|
+
private handleNetworkEventForEgressBlock;
|
|
338
|
+
/**
|
|
339
|
+
* Show the status-only overlay (launcher + message, no iframe) on a target
|
|
340
|
+
* that blocks the embedded panel. Injects the status bootstrap now AND as a
|
|
341
|
+
* `Page.addScriptToEvaluateOnNewDocument` hook so it survives app reloads —
|
|
342
|
+
* the status hook is added after the role hook, so re-running both leaves the
|
|
343
|
+
* idempotent launcher in the status-only state (the launcher is collapsed, so
|
|
344
|
+
* there is no visible iframe flash).
|
|
345
|
+
*/
|
|
346
|
+
private injectStatusOverlay;
|
|
265
347
|
private connectToTarget;
|
|
266
348
|
}
|
|
@@ -478,6 +478,33 @@ function buildFulfillResponseHeaders(rawHeaders, contentLength) {
|
|
|
478
478
|
}
|
|
479
479
|
return { responseHeaders, strippedCSP };
|
|
480
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* Empty-viewport message shown by the status-only overlay for an app that
|
|
483
|
+
* denies the overlay iframe's network egress. Kept in sync with swift-server's
|
|
484
|
+
* `overlayStatusMessageEgressBlocked`.
|
|
485
|
+
*/
|
|
486
|
+
export const OVERLAY_STATUS_MESSAGE_EGRESS_BLOCKED = 'SLICC is attached to this app, but it blocks embedded panels. Drive it from the SLICC leader window.';
|
|
487
|
+
/**
|
|
488
|
+
* CDP `Network.loadingFailed` `errorText` values that mean the *app itself*
|
|
489
|
+
* denied the overlay's document request at the network layer — not a CSP block.
|
|
490
|
+
* Signal (and other locked-down Electron apps) proxy all renderer network
|
|
491
|
+
* through their main process and deny external requests with
|
|
492
|
+
* `net::ERR_ACCESS_DENIED`, BENEATH the layer where `Page.setBypassCSP` or the
|
|
493
|
+
* CDP Fetch proxy operate — so the reload/Fetch escalation cannot help and must
|
|
494
|
+
* be skipped. Unlike a CSP block, the thrown probe (`OVERLAY_LOADED_PROBE_EXPRESSION`)
|
|
495
|
+
* can't distinguish this from a successful cross-origin load (both throw), so we
|
|
496
|
+
* detect it authoritatively from the failed document request instead.
|
|
497
|
+
*/
|
|
498
|
+
export const OVERLAY_EGRESS_BLOCK_ERROR_TEXTS = [
|
|
499
|
+
'net::ERR_ACCESS_DENIED',
|
|
500
|
+
'net::ERR_NETWORK_ACCESS_DENIED',
|
|
501
|
+
'net::ERR_BLOCKED_BY_CLIENT',
|
|
502
|
+
'net::ERR_BLOCKED_BY_ADMINISTRATOR',
|
|
503
|
+
];
|
|
504
|
+
/** True when `errorText` is one of {@link OVERLAY_EGRESS_BLOCK_ERROR_TEXTS}. */
|
|
505
|
+
export function isOverlayEgressBlockError(errorText) {
|
|
506
|
+
return typeof errorText === 'string' && OVERLAY_EGRESS_BLOCK_ERROR_TEXTS.includes(errorText);
|
|
507
|
+
}
|
|
481
508
|
/**
|
|
482
509
|
* JS probe that reports whether the overlay iframe actually loaded. Walks the
|
|
483
510
|
* `<slicc-launcher>` host's (open) shadow root to find the iframe
|
|
@@ -490,6 +517,13 @@ function buildFulfillResponseHeaders(rawHeaders, contentLength) {
|
|
|
490
517
|
* so the overlay did not load and the setBypassCSP escalation must fire.
|
|
491
518
|
* Returns `'ok'` only from the catch; otherwise `'no-host' / 'no-iframe' /
|
|
492
519
|
* 'no-src' / 'blank:<href>'`.
|
|
520
|
+
*
|
|
521
|
+
* CAVEAT: a *network-layer* block (the app denying the overlay's document
|
|
522
|
+
* request with `net::ERR_ACCESS_DENIED`) swaps to a CROSS-origin `chrome-error`
|
|
523
|
+
* page whose href access ALSO throws — indistinguishable here from a real load.
|
|
524
|
+
* That case is detected authoritatively from `Network.loadingFailed`
|
|
525
|
+
* (see {@link isOverlayEgressBlockError}); this probe's `'ok'` is overridden by
|
|
526
|
+
* the connection's `egressBlocked` flag so it is never recorded as loaded.
|
|
493
527
|
*/
|
|
494
528
|
export const OVERLAY_LOADED_PROBE_EXPRESSION = `(function() {
|
|
495
529
|
var host = document.getElementById('slicc-electron-overlay-root');
|
|
@@ -538,18 +572,40 @@ export class ElectronOverlayInjector {
|
|
|
538
572
|
presenceCheckIntervalMs;
|
|
539
573
|
connections = new Map();
|
|
540
574
|
cspBypassedTargets = new Set();
|
|
575
|
+
/**
|
|
576
|
+
* Per-process bridge token, present in every overlay app URL. Used to
|
|
577
|
+
* correlate a `Network.loadingFailed` back to OUR overlay iframe's document
|
|
578
|
+
* request (vs the app's own frames) when detecting an egress block.
|
|
579
|
+
*/
|
|
580
|
+
bridgeToken;
|
|
581
|
+
/**
|
|
582
|
+
* Targets whose overlay iframe was denied at the network layer by the app
|
|
583
|
+
* itself (e.g. Signal → `net::ERR_ACCESS_DENIED`). The reload/Fetch-proxy
|
|
584
|
+
* escalation cannot rescue these, so once a target is here we stop escalating
|
|
585
|
+
* and never record it as CSP-bypassed (which would falsely read as loaded).
|
|
586
|
+
*/
|
|
587
|
+
egressBlockedTargets = new Set();
|
|
541
588
|
/**
|
|
542
589
|
* URL of the target currently elected as the pinned leader. Cleared by
|
|
543
590
|
* `syncTargets` when that target disappears so the next injection
|
|
544
591
|
* re-elects a fresh leader.
|
|
545
592
|
*/
|
|
546
593
|
leaderTargetUrl = null;
|
|
594
|
+
/**
|
|
595
|
+
* Fired ONCE when the app is first detected to block renderer egress, so the
|
|
596
|
+
* runtime can start the headless CDP-over-CDP follower (`ElectronTrayFollower`)
|
|
597
|
+
* for this app. The hosted overlay can never load in such apps, so exposing
|
|
598
|
+
* their CDP to the tray leader is the only way to drive them.
|
|
599
|
+
*/
|
|
600
|
+
onEgressBlocked;
|
|
547
601
|
syncTimer = null;
|
|
548
602
|
syncing = false;
|
|
549
|
-
constructor(cdpPort, servePort, thinBootstraps, probeDelayMs = 1500, presenceCheckIntervalMs = ELECTRON_OVERLAY_PRESENCE_CHECK_INTERVAL_MS) {
|
|
603
|
+
constructor(cdpPort, servePort, thinBootstraps, bridgeToken, onEgressBlocked, probeDelayMs = 1500, presenceCheckIntervalMs = ELECTRON_OVERLAY_PRESENCE_CHECK_INTERVAL_MS) {
|
|
550
604
|
this.cdpPort = cdpPort;
|
|
551
605
|
this.servePort = servePort;
|
|
552
606
|
this.thinBootstraps = thinBootstraps;
|
|
607
|
+
this.bridgeToken = bridgeToken;
|
|
608
|
+
this.onEgressBlocked = onEgressBlocked;
|
|
553
609
|
this.probeDelayMs = probeDelayMs;
|
|
554
610
|
this.presenceCheckIntervalMs = presenceCheckIntervalMs;
|
|
555
611
|
}
|
|
@@ -566,8 +622,13 @@ export class ElectronOverlayInjector {
|
|
|
566
622
|
bundleSource,
|
|
567
623
|
appUrl: buildThinOverlayAppUrl({ ...options.thinBridge, role: BRIDGE_ROLE_FOLLOWER }),
|
|
568
624
|
}),
|
|
625
|
+
status: buildElectronOverlayBootstrapScript({
|
|
626
|
+
bundleSource,
|
|
627
|
+
appUrl: '',
|
|
628
|
+
statusMessage: OVERLAY_STATUS_MESSAGE_EGRESS_BLOCKED,
|
|
629
|
+
}),
|
|
569
630
|
};
|
|
570
|
-
return new ElectronOverlayInjector(options.cdpPort, options.servePort, thinBootstraps);
|
|
631
|
+
return new ElectronOverlayInjector(options.cdpPort, options.servePort, thinBootstraps, options.thinBridge.bridgeToken, options.onEgressBlocked);
|
|
571
632
|
}
|
|
572
633
|
/**
|
|
573
634
|
* Test-only factory: skips bundle loading and lets tests drive the per-target
|
|
@@ -575,7 +636,11 @@ export class ElectronOverlayInjector {
|
|
|
575
636
|
* `_testing_*` hooks on `ElectronOverlayInjector`.
|
|
576
637
|
*/
|
|
577
638
|
static _createForTesting(options) {
|
|
578
|
-
return new ElectronOverlayInjector(options.cdpPort ?? 9223, options.servePort, options.thinBootstraps ?? {
|
|
639
|
+
return new ElectronOverlayInjector(options.cdpPort ?? 9223, options.servePort, options.thinBootstraps ?? {
|
|
640
|
+
leader: '/* test-leader */',
|
|
641
|
+
follower: '/* test-follower */',
|
|
642
|
+
status: '/* test-status */',
|
|
643
|
+
}, options.bridgeToken ?? 'test-bridge-token', options.onEgressBlocked, options.probeDelayMs ?? 1500, options.presenceCheckIntervalMs ?? ELECTRON_OVERLAY_PRESENCE_CHECK_INTERVAL_MS);
|
|
579
644
|
}
|
|
580
645
|
/** Test-only: snapshot the elected leader target URL (null when no leader). */
|
|
581
646
|
_testingLeaderTargetUrl() {
|
|
@@ -597,6 +662,14 @@ export class ElectronOverlayInjector {
|
|
|
597
662
|
_testingBypassedTargets() {
|
|
598
663
|
return new Set(this.cspBypassedTargets);
|
|
599
664
|
}
|
|
665
|
+
/** Test-only: snapshot the set of targets marked egress-blocked. */
|
|
666
|
+
_testingEgressBlockedTargets() {
|
|
667
|
+
return new Set(this.egressBlockedTargets);
|
|
668
|
+
}
|
|
669
|
+
/** Test-only: seed a target as already egress-blocked. */
|
|
670
|
+
_testingSeedEgressBlockedTarget(url) {
|
|
671
|
+
this.egressBlockedTargets.add(url);
|
|
672
|
+
}
|
|
600
673
|
/** Test-only: drive a single `syncTargets` pass without `start()`'s interval. */
|
|
601
674
|
async _testingSyncTargets() {
|
|
602
675
|
await this.syncTargets();
|
|
@@ -842,6 +915,21 @@ export class ElectronOverlayInjector {
|
|
|
842
915
|
console.log(`[electron-float] Connected to target, bypassed=${alreadyBypassed}, url=${target.url}`);
|
|
843
916
|
send('Runtime.enable');
|
|
844
917
|
send('Page.enable');
|
|
918
|
+
// A target already known to block renderer egress cannot load the hosted
|
|
919
|
+
// overlay by any escalation — show the status-only overlay (no iframe)
|
|
920
|
+
// instead of re-running the doomed iframe injection + probe. (The
|
|
921
|
+
// stripped-down CDP-over-CDP follower path drives these apps.)
|
|
922
|
+
if (this.egressBlockedTargets.has(target.url)) {
|
|
923
|
+
state.egressBlocked = true;
|
|
924
|
+
console.log(`[electron-float] ${target.url} blocks renderer egress — injecting status-only overlay`);
|
|
925
|
+
this.injectStatusOverlay(send);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
// Watch the overlay iframe's document request so an app that denies it at
|
|
929
|
+
// the network layer (e.g. Signal → net::ERR_ACCESS_DENIED) is detected and
|
|
930
|
+
// the doomed CSP/Fetch escalation is skipped (see
|
|
931
|
+
// `handleNetworkEventForEgressBlock`).
|
|
932
|
+
send('Network.enable');
|
|
845
933
|
// Install the role bootstrap as a permanent new-document hook (parity with
|
|
846
934
|
// swift-server) so a full document reload / load-driven navigation of this
|
|
847
935
|
// already-connected target re-injects the overlay automatically. Without
|
|
@@ -887,12 +975,22 @@ export class ElectronOverlayInjector {
|
|
|
887
975
|
setTimeout(async () => {
|
|
888
976
|
if (ws.readyState !== WebSocket.OPEN)
|
|
889
977
|
return;
|
|
978
|
+
// Egress-blocked apps deny the overlay's document request beneath the
|
|
979
|
+
// layer setBypassCSP / the Fetch proxy operate at, so escalation cannot
|
|
980
|
+
// help. The probe's throw-based `'ok'` can't tell this apart from a real
|
|
981
|
+
// cross-origin load, so `egressBlocked` is authoritative: bail out
|
|
982
|
+
// WITHOUT recording the target as bypassed/loaded.
|
|
983
|
+
if (state.egressBlocked)
|
|
984
|
+
return;
|
|
890
985
|
const loaded = await this.probeOverlayIframeLoaded(ws, send);
|
|
891
|
-
if (loaded) {
|
|
986
|
+
if (loaded && !state.egressBlocked) {
|
|
892
987
|
console.log(`[electron-float] Overlay iframe loaded successfully — no CSP reload needed`);
|
|
893
988
|
this.cspBypassedTargets.add(target.url);
|
|
989
|
+
send('Network.disable');
|
|
894
990
|
return;
|
|
895
991
|
}
|
|
992
|
+
if (state.egressBlocked)
|
|
993
|
+
return;
|
|
896
994
|
// Phase 2: Page.setBypassCSP was already set — a simple reload should
|
|
897
995
|
// make the browser ignore CSP headers on the fresh navigation.
|
|
898
996
|
// Deliberately do NOT recordBypassed yet — if the CDP session
|
|
@@ -937,12 +1035,20 @@ export class ElectronOverlayInjector {
|
|
|
937
1035
|
setTimeout(async () => {
|
|
938
1036
|
if (ws.readyState !== WebSocket.OPEN)
|
|
939
1037
|
return;
|
|
1038
|
+
// Network-egress block detected during/after the reload — the Fetch
|
|
1039
|
+
// proxy can't help either (it intercepts above the denying layer), so
|
|
1040
|
+
// stop here without recording the target as loaded.
|
|
1041
|
+
if (state.egressBlocked)
|
|
1042
|
+
return;
|
|
940
1043
|
const loaded = await this.probeOverlayIframeLoaded(ws, send);
|
|
941
|
-
if (loaded) {
|
|
1044
|
+
if (loaded && !state.egressBlocked) {
|
|
942
1045
|
console.log(`[electron-float] Overlay iframe loaded after CSP reload — no proxy needed`);
|
|
943
1046
|
this.cspBypassedTargets.add(target.url);
|
|
1047
|
+
send('Network.disable');
|
|
944
1048
|
return;
|
|
945
1049
|
}
|
|
1050
|
+
if (state.egressBlocked)
|
|
1051
|
+
return;
|
|
946
1052
|
const fetchOrigin = resolveFetchProxyOrigin(target.url, this.servePort);
|
|
947
1053
|
console.log(`[electron-float] CSP reload insufficient, escalating to Fetch proxy: target=${target.url} origin=${fetchOrigin}`);
|
|
948
1054
|
state.fetchProxyActive = true;
|
|
@@ -1021,6 +1127,60 @@ export class ElectronOverlayInjector {
|
|
|
1021
1127
|
}
|
|
1022
1128
|
proxyReq.end();
|
|
1023
1129
|
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Inspect a `Network.*` CDP event for the egress-block signal: track OUR
|
|
1132
|
+
* overlay iframe's top-level Document request (matched by the per-process
|
|
1133
|
+
* bridge token in its URL, so the app's own frames are ignored) and, when the
|
|
1134
|
+
* app denies it at the network layer ({@link isOverlayEgressBlockError}), set
|
|
1135
|
+
* `state.egressBlocked` and record the target. `Page.setBypassCSP` / the Fetch
|
|
1136
|
+
* proxy operate above the layer that denies these, so escalation cannot help —
|
|
1137
|
+
* the probe/escalation paths read `state.egressBlocked` to bail out instead of
|
|
1138
|
+
* reload-churning and instead of falsely recording the target as loaded.
|
|
1139
|
+
*/
|
|
1140
|
+
handleNetworkEventForEgressBlock(msg, send, target, state) {
|
|
1141
|
+
const params = msg.params;
|
|
1142
|
+
if (!params)
|
|
1143
|
+
return;
|
|
1144
|
+
if (msg.method === 'Network.requestWillBeSent') {
|
|
1145
|
+
if (params.type === 'Document' &&
|
|
1146
|
+
typeof params.requestId === 'string' &&
|
|
1147
|
+
typeof params.request?.url === 'string' &&
|
|
1148
|
+
params.request.url.includes(this.bridgeToken)) {
|
|
1149
|
+
state.overlayRequestIds.add(params.requestId);
|
|
1150
|
+
}
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (msg.method === 'Network.loadingFailed' &&
|
|
1154
|
+
typeof params.requestId === 'string' &&
|
|
1155
|
+
state.overlayRequestIds.has(params.requestId) &&
|
|
1156
|
+
isOverlayEgressBlockError(params.errorText)) {
|
|
1157
|
+
state.egressBlocked = true;
|
|
1158
|
+
// Determination made — stop the Network event stream for this connection.
|
|
1159
|
+
send('Network.disable');
|
|
1160
|
+
if (!this.egressBlockedTargets.has(target.url)) {
|
|
1161
|
+
this.egressBlockedTargets.add(target.url);
|
|
1162
|
+
console.log(`[electron-float] Overlay blocked by app network egress (${params.errorText}); ` +
|
|
1163
|
+
`the hosted overlay cannot load in ${target.url} — skipping CSP/Fetch escalation. ` +
|
|
1164
|
+
`Egress-blocked apps need the CDP-over-CDP follower path.`);
|
|
1165
|
+
// Start the headless CDP-over-CDP follower for this app (once).
|
|
1166
|
+
this.onEgressBlocked?.(target.url);
|
|
1167
|
+
}
|
|
1168
|
+
// Replace the (blank-iframe) overlay with the status-only launcher.
|
|
1169
|
+
this.injectStatusOverlay(send);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Show the status-only overlay (launcher + message, no iframe) on a target
|
|
1174
|
+
* that blocks the embedded panel. Injects the status bootstrap now AND as a
|
|
1175
|
+
* `Page.addScriptToEvaluateOnNewDocument` hook so it survives app reloads —
|
|
1176
|
+
* the status hook is added after the role hook, so re-running both leaves the
|
|
1177
|
+
* idempotent launcher in the status-only state (the launcher is collapsed, so
|
|
1178
|
+
* there is no visible iframe flash).
|
|
1179
|
+
*/
|
|
1180
|
+
injectStatusOverlay(send) {
|
|
1181
|
+
send('Page.addScriptToEvaluateOnNewDocument', { source: this.thinBootstraps.status });
|
|
1182
|
+
send('Runtime.evaluate', { expression: this.thinBootstraps.status, awaitPromise: false });
|
|
1183
|
+
}
|
|
1024
1184
|
connectToTarget(target) {
|
|
1025
1185
|
const targetId = target.webSocketDebuggerUrl;
|
|
1026
1186
|
const ws = new WebSocket(targetId);
|
|
@@ -1035,6 +1195,8 @@ export class ElectronOverlayInjector {
|
|
|
1035
1195
|
pendingReload: false,
|
|
1036
1196
|
pendingCspEscalation: false,
|
|
1037
1197
|
fetchProxyActive: false,
|
|
1198
|
+
egressBlocked: false,
|
|
1199
|
+
overlayRequestIds: new Set(),
|
|
1038
1200
|
};
|
|
1039
1201
|
// Periodic presence re-check: covers SPAs that re-render their DOM root
|
|
1040
1202
|
// (evicting the overlay) without firing a navigation event. Cleared on
|
|
@@ -1079,6 +1241,11 @@ export class ElectronOverlayInjector {
|
|
|
1079
1241
|
if (msg.method === 'Fetch.requestPaused' && state.fetchProxyActive) {
|
|
1080
1242
|
this.handleFetchRequestPaused(ws, send, msg);
|
|
1081
1243
|
}
|
|
1244
|
+
// Egress-block detection: track OUR overlay iframe's Document request
|
|
1245
|
+
// (matched by the per-process bridge token in the URL) and, if the app
|
|
1246
|
+
// denies it at the network layer, mark the target so the doomed
|
|
1247
|
+
// reload/Fetch escalation is skipped and it is never recorded as loaded.
|
|
1248
|
+
this.handleNetworkEventForEgressBlock(msg, send, target, state);
|
|
1082
1249
|
}
|
|
1083
1250
|
catch {
|
|
1084
1251
|
// Ignore parse errors for non-JSON messages
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type FollowerToLeaderMessage } from './_shared/index.js';
|
|
2
|
+
/** A CDP target as returned by `/json/list`. */
|
|
3
|
+
export interface FederatedCdpInspectableTarget {
|
|
4
|
+
id: string;
|
|
5
|
+
type: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
url: string;
|
|
8
|
+
webSocketDebuggerUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Leader → follower CDP request (the `cdp.request` tray-sync variant). */
|
|
11
|
+
export interface FederatedCdpRequest {
|
|
12
|
+
requestId: string;
|
|
13
|
+
localTargetId: string;
|
|
14
|
+
method: string;
|
|
15
|
+
params?: Record<string, unknown>;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build a `targets.advertise` message advertising the app's page targets to the
|
|
20
|
+
* leader (which namespaces them into its aggregated `targets.registry`). Only
|
|
21
|
+
* `page`-type targets are exposed — devtools/service-worker/etc. are not
|
|
22
|
+
* driveable follower surfaces. `targetId` is the app's LOCAL CDP target id.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildTargetsAdvertise(runtimeId: string, targets: FederatedCdpInspectableTarget[]): Extract<FollowerToLeaderMessage, {
|
|
25
|
+
type: 'targets.advertise';
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Translate a raw CDP result/error into the `cdp.response` message(s) to send
|
|
29
|
+
* back to the leader. Delegates to the shared `sendCDPResponse` chunker so an
|
|
30
|
+
* oversize result is split into `chunkData` frames the leader reassembles.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildCdpResponses(requestId: string, outcome: {
|
|
33
|
+
result?: Record<string, unknown>;
|
|
34
|
+
error?: string;
|
|
35
|
+
}): Array<Extract<FollowerToLeaderMessage, {
|
|
36
|
+
type: 'cdp.response';
|
|
37
|
+
}>>;
|
|
38
|
+
/** Translate a raw CDP event frame into a `cdp.event` tray-sync message. */
|
|
39
|
+
export declare function buildCdpEvent(frame: {
|
|
40
|
+
method: string;
|
|
41
|
+
params?: Record<string, unknown>;
|
|
42
|
+
sessionId?: string;
|
|
43
|
+
}): Extract<FollowerToLeaderMessage, {
|
|
44
|
+
type: 'cdp.event';
|
|
45
|
+
}>;
|
|
46
|
+
/**
|
|
47
|
+
* Connects to the attached app's raw CDP and relays tray-sync CDP messages
|
|
48
|
+
* to/from a leader over an injected transport. Transparent: the leader manages
|
|
49
|
+
* Target attachment / sessions itself (via forwarded `Target.*` requests), so
|
|
50
|
+
* this servicer only maps `requestId ↔ CDP id` and translates envelopes.
|
|
51
|
+
*/
|
|
52
|
+
export declare class ElectronFederatedCdp {
|
|
53
|
+
private readonly runtimeId;
|
|
54
|
+
private readonly send;
|
|
55
|
+
private ws;
|
|
56
|
+
private nextCdpId;
|
|
57
|
+
/** CDP frame id → leader requestId, for correlating responses. */
|
|
58
|
+
private readonly pending;
|
|
59
|
+
constructor(options: {
|
|
60
|
+
runtimeId: string;
|
|
61
|
+
send: (message: FollowerToLeaderMessage) => void;
|
|
62
|
+
});
|
|
63
|
+
/** Open the CDP connection to the browser-level debugger endpoint. */
|
|
64
|
+
connect(browserWebSocketDebuggerUrl: string): Promise<void>;
|
|
65
|
+
/** Advertise the app's page targets to the leader (from `/json/list`). */
|
|
66
|
+
advertiseTargets(targets: FederatedCdpInspectableTarget[]): void;
|
|
67
|
+
/** Service one leader `cdp.request`: forward it to the app's CDP (preserving
|
|
68
|
+
* `sessionId`) and correlate the eventual response by requestId. */
|
|
69
|
+
handleCdpRequest(request: FederatedCdpRequest): void;
|
|
70
|
+
/** Close the CDP connection and reject any in-flight requests. */
|
|
71
|
+
stop(): void;
|
|
72
|
+
private onCdpFrame;
|
|
73
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Server-side federated-CDP servicer — the "expose CDP over CDP" core for
|
|
2
|
+
// egress-blocked Electron apps (e.g. Signal).
|
|
3
|
+
//
|
|
4
|
+
// A normal SLICC follower runs the webapp in the target's renderer and lets the
|
|
5
|
+
// leader drive its browser over the tray sync protocol (`cdp.request` →
|
|
6
|
+
// `cdp.response`/`cdp.event`, `targets.registry`). Signal's renderer blocks all
|
|
7
|
+
// network egress, so the webapp can't run there — but the slicc-server DOES have
|
|
8
|
+
// the app's raw CDP (the `--cdp-port` it launched Signal with). This servicer
|
|
9
|
+
// makes the SERVER the follower's CDP surface: it connects to that raw CDP and
|
|
10
|
+
// translates the leader's tray-sync CDP messages to/from it, transparently.
|
|
11
|
+
//
|
|
12
|
+
// It is TRANSPORT-AGNOSTIC: the caller supplies a `send(FollowerToLeaderMessage)`
|
|
13
|
+
// sink and feeds it leader `cdp.request`s via `handleCdpRequest`. Wiring the
|
|
14
|
+
// WebRTC data channel (tray-webrtc) that carries these messages to/from the
|
|
15
|
+
// leader is a separate phase — WebRTC is proven to work from Signal's renderer,
|
|
16
|
+
// and its signalling tunnels over the CDP binding. The translation core here is
|
|
17
|
+
// pure + unit-tested, and the CDP driver is validated against a live target.
|
|
18
|
+
import { sendCDPResponse, } from './_shared/index.js';
|
|
19
|
+
import { WebSocket } from 'ws';
|
|
20
|
+
/**
|
|
21
|
+
* Build a `targets.advertise` message advertising the app's page targets to the
|
|
22
|
+
* leader (which namespaces them into its aggregated `targets.registry`). Only
|
|
23
|
+
* `page`-type targets are exposed — devtools/service-worker/etc. are not
|
|
24
|
+
* driveable follower surfaces. `targetId` is the app's LOCAL CDP target id.
|
|
25
|
+
*/
|
|
26
|
+
export function buildTargetsAdvertise(runtimeId, targets) {
|
|
27
|
+
const entries = targets
|
|
28
|
+
.filter((t) => t.type === 'page')
|
|
29
|
+
.map((t) => ({
|
|
30
|
+
targetId: t.id,
|
|
31
|
+
title: t.title ?? '',
|
|
32
|
+
url: t.url,
|
|
33
|
+
kind: 'browser',
|
|
34
|
+
}));
|
|
35
|
+
return { type: 'targets.advertise', targets: entries, runtimeId };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Translate a raw CDP result/error into the `cdp.response` message(s) to send
|
|
39
|
+
* back to the leader. Delegates to the shared `sendCDPResponse` chunker so an
|
|
40
|
+
* oversize result is split into `chunkData` frames the leader reassembles.
|
|
41
|
+
*/
|
|
42
|
+
export function buildCdpResponses(requestId, outcome) {
|
|
43
|
+
const messages = [];
|
|
44
|
+
sendCDPResponse({
|
|
45
|
+
send: (message) => {
|
|
46
|
+
if (message.type === 'cdp.response') {
|
|
47
|
+
messages.push(message);
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
}, requestId, outcome.result, outcome.error);
|
|
52
|
+
return messages;
|
|
53
|
+
}
|
|
54
|
+
/** Translate a raw CDP event frame into a `cdp.event` tray-sync message. */
|
|
55
|
+
export function buildCdpEvent(frame) {
|
|
56
|
+
return {
|
|
57
|
+
type: 'cdp.event',
|
|
58
|
+
method: frame.method,
|
|
59
|
+
params: frame.params ?? {},
|
|
60
|
+
sessionId: frame.sessionId,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Connects to the attached app's raw CDP and relays tray-sync CDP messages
|
|
65
|
+
* to/from a leader over an injected transport. Transparent: the leader manages
|
|
66
|
+
* Target attachment / sessions itself (via forwarded `Target.*` requests), so
|
|
67
|
+
* this servicer only maps `requestId ↔ CDP id` and translates envelopes.
|
|
68
|
+
*/
|
|
69
|
+
export class ElectronFederatedCdp {
|
|
70
|
+
runtimeId;
|
|
71
|
+
send;
|
|
72
|
+
ws = null;
|
|
73
|
+
nextCdpId = 1;
|
|
74
|
+
/** CDP frame id → leader requestId, for correlating responses. */
|
|
75
|
+
pending = new Map();
|
|
76
|
+
constructor(options) {
|
|
77
|
+
this.runtimeId = options.runtimeId;
|
|
78
|
+
this.send = options.send;
|
|
79
|
+
}
|
|
80
|
+
/** Open the CDP connection to the browser-level debugger endpoint. */
|
|
81
|
+
async connect(browserWebSocketDebuggerUrl) {
|
|
82
|
+
const ws = new WebSocket(browserWebSocketDebuggerUrl);
|
|
83
|
+
this.ws = ws;
|
|
84
|
+
ws.on('message', (data) => this.onCdpFrame(data.toString()));
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
ws.once('open', () => resolve());
|
|
87
|
+
ws.once('error', (err) => reject(err));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Advertise the app's page targets to the leader (from `/json/list`). */
|
|
91
|
+
advertiseTargets(targets) {
|
|
92
|
+
this.send(buildTargetsAdvertise(this.runtimeId, targets));
|
|
93
|
+
}
|
|
94
|
+
/** Service one leader `cdp.request`: forward it to the app's CDP (preserving
|
|
95
|
+
* `sessionId`) and correlate the eventual response by requestId. */
|
|
96
|
+
handleCdpRequest(request) {
|
|
97
|
+
const ws = this.ws;
|
|
98
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
99
|
+
for (const message of buildCdpResponses(request.requestId, { error: 'cdp-not-connected' })) {
|
|
100
|
+
this.send(message);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const id = this.nextCdpId++;
|
|
105
|
+
this.pending.set(id, request.requestId);
|
|
106
|
+
const frame = { id, method: request.method, params: request.params ?? {} };
|
|
107
|
+
if (request.sessionId)
|
|
108
|
+
frame.sessionId = request.sessionId;
|
|
109
|
+
try {
|
|
110
|
+
ws.send(JSON.stringify(frame));
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
// A socket that goes CLOSING between the OPEN check above and here makes
|
|
114
|
+
// `ws.send` throw synchronously; without this the leader's requestId is
|
|
115
|
+
// stranded until `stop()` rejects it (long after the leader's own
|
|
116
|
+
// timeout). Mirrors FederatedCDPServicer.handleCdpRequest in swift-server.
|
|
117
|
+
this.pending.delete(id);
|
|
118
|
+
for (const message of buildCdpResponses(request.requestId, {
|
|
119
|
+
error: `cdp-send-failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
120
|
+
})) {
|
|
121
|
+
this.send(message);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Close the CDP connection and reject any in-flight requests. */
|
|
126
|
+
stop() {
|
|
127
|
+
for (const requestId of this.pending.values()) {
|
|
128
|
+
for (const message of buildCdpResponses(requestId, { error: 'cdp-closed' })) {
|
|
129
|
+
this.send(message);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
this.pending.clear();
|
|
133
|
+
try {
|
|
134
|
+
this.ws?.close();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// ignore close failures
|
|
138
|
+
}
|
|
139
|
+
this.ws = null;
|
|
140
|
+
}
|
|
141
|
+
onCdpFrame(raw) {
|
|
142
|
+
let frame;
|
|
143
|
+
try {
|
|
144
|
+
frame = JSON.parse(raw);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// A response carries an `id`; an event carries a `method` and no `id`.
|
|
150
|
+
if (typeof frame.id === 'number') {
|
|
151
|
+
const requestId = this.pending.get(frame.id);
|
|
152
|
+
if (requestId === undefined)
|
|
153
|
+
return;
|
|
154
|
+
this.pending.delete(frame.id);
|
|
155
|
+
const outcome = frame.error
|
|
156
|
+
? { error: frame.error.message ?? 'cdp-error' }
|
|
157
|
+
: { result: frame.result ?? {} };
|
|
158
|
+
for (const message of buildCdpResponses(requestId, outcome))
|
|
159
|
+
this.send(message);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (typeof frame.method === 'string') {
|
|
163
|
+
this.send(buildCdpEvent({ method: frame.method, params: frame.params, sessionId: frame.sessionId }));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|