sliccy 6.39.1 → 6.40.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.
Files changed (85) hide show
  1. package/dist/node-server/electron-controller.d.ts +82 -0
  2. package/dist/node-server/electron-controller.js +172 -5
  3. package/dist/node-server/electron-federated-cdp.d.ts +73 -0
  4. package/dist/node-server/electron-federated-cdp.js +166 -0
  5. package/dist/node-server/electron-runtime.d.ts +4 -0
  6. package/dist/node-server/electron-runtime.js +3 -0
  7. package/dist/node-server/electron-tray-follower.d.ts +144 -0
  8. package/dist/node-server/electron-tray-follower.js +463 -0
  9. package/dist/node-server/index.js +54 -2
  10. package/dist/ui/assets/account-store-BY3aXBTq.js +100 -0
  11. package/dist/ui/assets/{account-store-8zHKHzZf.js → account-store-Dt0wuDIn.js} +1 -1
  12. package/dist/ui/assets/{adobe-CD47VxQ1.js → adobe-BGcNp7ud.js} +1 -1
  13. package/dist/ui/assets/{adobe-C__bPqu-.js → adobe-D0Vk4L9j.js} +1 -1
  14. package/dist/ui/assets/{apply-layout-DaQTyGVJ.js → apply-layout-7i_CAtSn.js} +1 -1
  15. package/dist/ui/assets/{apps-BSqeYsFl.js → apps-5ql07RBk.js} +1 -1
  16. package/dist/ui/assets/{azure-openai-DAJX2mxQ.js → azure-openai-BietzfDv.js} +1 -1
  17. package/dist/ui/assets/{azure-openai-BfVVshcW.js → azure-openai-DmSEPug5.js} +1 -1
  18. package/dist/ui/assets/{cerebras-DH7EiStU.js → cerebras-BXyQxeH5.js} +1 -1
  19. package/dist/ui/assets/{cerebras-kGQ6eAhx.js → cerebras-CucwQEOZ.js} +1 -1
  20. package/dist/ui/assets/{connect-surface-BWUXCSRt.js → connect-surface-BcFfYqAg.js} +1 -1
  21. package/dist/ui/assets/{fs-C0NPGido.js → fs-1lpjBcH8.js} +1 -1
  22. package/dist/ui/assets/{fs-Cequurmj.js → fs-zjT7aP3D.js} +2 -2
  23. package/dist/ui/assets/{github-Zugg5Qj-.js → github-BljoTMry.js} +2 -2
  24. package/dist/ui/assets/{github-copilot-kBcOKHxp.js → github-copilot--DaLg0Yi.js} +1 -1
  25. package/dist/ui/assets/{github-copilot-Bo9JF5a-.js → github-copilot-BFx9SGae.js} +1 -1
  26. package/dist/ui/assets/{github-5sgbo7jZ.js → github-i-WMYM95.js} +1 -1
  27. package/dist/ui/assets/{github-install-CvpnvL5c.js → github-install-B_7XU6lL.js} +1 -1
  28. package/dist/ui/assets/{github-zip-BWQMOpzT.js → github-zip-DR8Okl6i.js} +1 -1
  29. package/dist/ui/assets/{kernel-worker-C-AHxfuN.js → kernel-worker-DAtKQ0nE.js} +25 -25
  30. package/dist/ui/assets/{local-llm-C7Qt3nVY.js → local-llm-C10aQlRE.js} +1 -1
  31. package/dist/ui/assets/{local-llm-PR0ztiYK.js → local-llm-Cx0afaWx.js} +1 -1
  32. package/dist/ui/assets/{main-CjKZsW9X.js → main-CJl81FT6.js} +3 -3
  33. package/dist/ui/assets/{mount-DrKRVo4a.js → mount-C3eFehs3.js} +1 -1
  34. package/dist/ui/assets/{mount-BS2IDRu-.js → mount-DB6C8U3-.js} +2 -2
  35. package/dist/ui/assets/{mount-recovery-Cln0Kwvu.js → mount-recovery-Ck1JM_Xv.js} +1 -1
  36. package/dist/ui/assets/{new-session-BUyVHhRL.js → new-session-CNFDAwZC.js} +2 -2
  37. package/dist/ui/assets/{oauth-bootstrap-ChJOKAmZ.js → oauth-bootstrap-C0v-Q0DW.js} +2 -2
  38. package/dist/ui/assets/{openai-codex-DBXsg0MM.js → openai-codex-Bd4bYpX3.js} +1 -1
  39. package/dist/ui/assets/{openai-codex-DcvV7I52.js → openai-codex-V9JbtyRd.js} +1 -1
  40. package/dist/ui/assets/{openrouter-D52jlKXD.js → openrouter-CQgdHpTG.js} +1 -1
  41. package/dist/ui/assets/{openrouter-DBBBsBmt.js → openrouter-D-FzyKnA.js} +1 -1
  42. package/dist/ui/assets/{openrouter-oauth-jbgq6X1-.js → openrouter-oauth-BdAmWLQG.js} +1 -1
  43. package/dist/ui/assets/{openrouter-oauth-D35pGOT5.js → openrouter-oauth-Dvvl9Cva.js} +1 -1
  44. package/dist/ui/assets/{panel-rpc-handlers-gpN6c-a2.js → panel-rpc-handlers-C2U2bPzL.js} +2 -2
  45. package/dist/ui/assets/{panelize-shell-DeP-Mot2.js → panelize-shell-DMbSe6Cp.js} +1 -1
  46. package/dist/ui/assets/{provider-wlWK7FGl.js → provider-CO-efbUO.js} +2 -2
  47. package/dist/ui/assets/{provider-BUmrpCDs.js → provider-D8qPAyiY.js} +1 -1
  48. package/dist/ui/assets/{provider-settings-DEeVOQVO.js → provider-settings-BqgTrNd0.js} +1 -1
  49. package/dist/ui/assets/{provider-settings-E2_3_Nn0.js → provider-settings-CsNUnYS7.js} +1 -1
  50. package/dist/ui/assets/provider-store-access-C6cSTLmi.js +1 -0
  51. package/dist/ui/assets/{provider-store-access-BNb6Ckg9.js → provider-store-access-Dop6FohJ.js} +2 -2
  52. package/dist/ui/assets/{providers-CBlACIFz.js → providers-Bcv36n05.js} +2 -2
  53. package/dist/ui/assets/{providers-D91y678Z.js → providers-C40GgLqQ.js} +1 -1
  54. package/dist/ui/assets/{quick-llm-TL2ngvw6.js → quick-llm-3eP55gY-.js} +1 -1
  55. package/dist/ui/assets/{recovery-screen-B2hwv_k3.js → recovery-screen-DdFC2GOd.js} +1 -1
  56. package/dist/ui/assets/{redirect-uri-COdP9xBw.js → redirect-uri-D3EF2enT.js} +1 -1
  57. package/dist/ui/assets/{session-freezer-lTtNV89v.js → session-freezer-7CtqVCH5.js} +2 -2
  58. package/dist/ui/assets/{setup-feature-flags-remote-BdWq_ZXF.js → setup-feature-flags-remote-BYl7a7uw.js} +1 -1
  59. package/dist/ui/assets/{setup-standalone-prelude-U4VmZy8w.js → setup-standalone-prelude-_3OVDJrq.js} +1 -1
  60. package/dist/ui/assets/{setup-sudo-BIhsJd5L.js → setup-sudo-DhnUoSO-.js} +2 -2
  61. package/dist/ui/assets/{skills-Bh7wIvj-.js → skills-C1o--fSs.js} +1 -1
  62. package/dist/ui/assets/{src-WmaDIMoL.js → src-iJcLCxRd.js} +103 -103
  63. package/dist/ui/assets/{store-B0ZSErNM.js → store-Bz3wYNXR.js} +1 -1
  64. package/dist/ui/assets/{store-Cbq0lKAx.js → store-Dg0q78qC.js} +1 -1
  65. package/dist/ui/assets/{sudo-DPfno2Fe.js → sudo-vcErFxyt.js} +1 -1
  66. package/dist/ui/assets/{upgrade-detection-DLES83VK.js → upgrade-detection-WFIpSR9L.js} +1 -1
  67. package/dist/ui/assets/{wc-attach-BRFtXk_B.js → wc-attach-ZYwsizqf.js} +2 -2
  68. package/dist/ui/assets/{wc-extension-Bh63DngU.js → wc-extension-ExktUfXh.js} +2 -2
  69. package/dist/ui/assets/{wc-follower-_jlSzC2-.js → wc-follower-D6d-q56W.js} +3 -3
  70. package/dist/ui/assets/{wc-live-pHaHvtg9.js → wc-live-CxALPjhv.js} +3 -3
  71. package/dist/ui/assets/{wc-nav-nGeFgw0Y.js → wc-nav-DCCBQZ-C.js} +2 -2
  72. package/dist/ui/assets/{wc-onboarding-C5rtuepw.js → wc-onboarding-CTJOFFrA.js} +2 -2
  73. package/dist/ui/assets/{wc-placeholder-C0YTlp-D.js → wc-placeholder-B8xuHsKi.js} +2 -2
  74. package/dist/ui/assets/{wc-settings-Bzucq5Ey.js → wc-settings-BBHQqfPX.js} +2 -2
  75. package/dist/ui/assets/{wc-shell-C4nE6X2z.js → wc-shell-BAh53EB-.js} +3 -3
  76. package/dist/ui/assets/{wc-sprinkles-CiwBe9NF.js → wc-sprinkles-BagZ1zt0.js} +2 -2
  77. package/dist/ui/assets/{wc-tray-CAvYhNoy.js → wc-tray-Dsqe3lyy.js} +2 -2
  78. package/dist/ui/assets/{xai-grok-C1YVEpmg.js → xai-grok-8VzWYOXJ.js} +1 -1
  79. package/dist/ui/assets/{xai-grok-CZvU2oSp.js → xai-grok-DmTanlk8.js} +1 -1
  80. package/dist/ui/electron-overlay-entry.js +10 -10
  81. package/dist/ui/index.html +6 -6
  82. package/dist/ui/packages/webapp/index.html +6 -6
  83. package/package.json +2 -1
  84. package/dist/ui/assets/account-store-Cxz3loXT.js +0 -100
  85. package/dist/ui/assets/provider-store-access-BpxhTrT1.js +0 -1
@@ -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 ?? { leader: '/* test-leader */', follower: '/* test-follower */' }, options.probeDelayMs ?? 1500, options.presenceCheckIntervalMs ?? ELECTRON_OVERLAY_PRESENCE_CHECK_INTERVAL_MS);
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
+ }
@@ -63,12 +63,16 @@ export declare function buildElectronOverlayInjectionCall(options: {
63
63
  appUrl: string;
64
64
  open?: boolean;
65
65
  activeTab?: string;
66
+ /** Empty-viewport message for the status-only overlay (apps that block the
67
+ * embedded panel, e.g. Signal). Only meaningful when `appUrl` is empty. */
68
+ statusMessage?: string;
66
69
  }): string;
67
70
  export declare function buildElectronOverlayBootstrapScript(options: {
68
71
  bundleSource: string;
69
72
  appUrl: string;
70
73
  open?: boolean;
71
74
  activeTab?: string;
75
+ statusMessage?: string;
72
76
  }): string;
73
77
  export declare function shouldInjectElectronOverlayTarget(target: ElectronInspectableTarget): boolean;
74
78
  /**