sliccy 3.44.0 → 3.45.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 (60) hide show
  1. package/README.md +1 -1
  2. package/dist/node-server/electron-controller.d.ts +66 -0
  3. package/dist/node-server/electron-controller.js +351 -227
  4. package/dist/ui/assets/{adobe-ChVGhtP9.js → adobe-Ds812-ra.js} +1 -1
  5. package/dist/ui/assets/{adobe-DfX5Y34s.js → adobe-ivtPYmqg.js} +2 -2
  6. package/dist/ui/assets/{agent-bridge-C5enj1WV.js → agent-bridge-DmfLQBK6.js} +1 -1
  7. package/dist/ui/assets/{agent-message-to-chat-FbjZc5O-.js → agent-message-to-chat-D16PaAN7.js} +1 -1
  8. package/dist/ui/assets/{apps-Crb9N_ly.js → apps-CwAAMuoH.js} +1 -1
  9. package/dist/ui/assets/{azure-openai-C4klGbBq.js → azure-openai-5r7wuCMI.js} +1 -1
  10. package/dist/ui/assets/{azure-openai-z_V4V786.js → azure-openai-BA361JV-.js} +1 -1
  11. package/dist/ui/assets/{bsh-watchdog-X8O_f3CL.js → bsh-watchdog-CxNaT8-y.js} +1 -1
  12. package/dist/ui/assets/{cdp-rsA_ALer.js → cdp-CKoozPln.js} +3 -3
  13. package/dist/ui/assets/{connect-surface-KLfqgi-g.js → connect-surface-DzyDnkx5.js} +1 -1
  14. package/dist/ui/assets/cost-command-ZUZVbObe.js +1 -0
  15. package/dist/ui/assets/{dist-LiJ_YnhF.js → dist-DofwLk0q.js} +1 -1
  16. package/dist/ui/assets/{dist-Bm5Z71wh.js → dist-rRyNM8rd.js} +1 -1
  17. package/dist/ui/assets/{es-BJGCk_7v.js → es-5UWQdOB0.js} +1 -1
  18. package/dist/ui/assets/{follower-sprinkle-bridge-Q2unDn_f.js → follower-sprinkle-bridge-viWRCD5z.js} +1 -1
  19. package/dist/ui/assets/{fs-OBeSd3iT.js → fs-C9UBBI7S.js} +1 -1
  20. package/dist/ui/assets/{github-Ca90QHu7.js → github-CkIRWF-f.js} +2 -2
  21. package/dist/ui/assets/{github-kiWl2pok.js → github-DBXkIqTY.js} +1 -1
  22. package/dist/ui/assets/{github-copilot-BrwsrJ0a.js → github-copilot-DiVnQ5Uo.js} +1 -1
  23. package/dist/ui/assets/{github-copilot-CwAq3lpN.js → github-copilot-IziFP5rO.js} +1 -1
  24. package/dist/ui/assets/{kernel-worker-D7kDM6z5.js → kernel-worker-FZOMNCj9.js} +835 -835
  25. package/dist/ui/assets/lick-ws-bridge-CyGSwbAX.js +1 -0
  26. package/dist/ui/assets/{local-llm-BlEhi-EE.js → local-llm-C7XwzBbN.js} +1 -1
  27. package/dist/ui/assets/{magick-wasm-1dtYEyLr.js → magick-wasm-Bmrjpi0U.js} +1 -1
  28. package/dist/ui/assets/{main-DQNfRwQ_.js → main-P7Vi7TpQ.js} +169 -169
  29. package/dist/ui/assets/{main-cherry-zUoPE3cg.js → main-cherry-D-OWd1Kw.js} +1 -1
  30. package/dist/ui/assets/{migration-run--a8o7LGm.js → migration-run-cqQXJZcY.js} +1 -1
  31. package/dist/ui/assets/{mount-B7zmIwVP.js → mount-CvghmQkc.js} +1 -1
  32. package/dist/ui/assets/{nuke-command-DlkLMgeD.js → nuke-command-CSv_S5nD.js} +1 -1
  33. package/dist/ui/assets/{oauth-bootstrap--CDdXr74.js → oauth-bootstrap-B8y2pYWG.js} +2 -2
  34. package/dist/ui/assets/{oauth-service-BcS-bhoB.js → oauth-service-Bv6jDVRT.js} +1 -1
  35. package/dist/ui/assets/{onboarding-orchestrator-Dpk9ts40.js → onboarding-orchestrator-c5JgScm0.js} +1 -1
  36. package/dist/ui/assets/{openai-codex-DzqY9hsC.js → openai-codex-BGqtCHJ8.js} +1 -1
  37. package/dist/ui/assets/{openai-codex-D1-QVrzy.js → openai-codex-Bz9CHxK-.js} +1 -1
  38. package/dist/ui/assets/{panel-rpc-handlers-CnJYQP27.js → panel-rpc-handlers-pimI-6NM.js} +1 -1
  39. package/dist/ui/assets/{provider-BiMU6jfy.js → provider-B5YQBKBu.js} +2 -2
  40. package/dist/ui/assets/{provider-BBaMH9k3.js → provider-CI9uKbU6.js} +1 -1
  41. package/dist/ui/assets/{provider-settings-Cg_hqZuj.js → provider-settings-C6iSqI1N.js} +2 -2
  42. package/dist/ui/assets/provider-store-access-BpS14jY1.js +1 -0
  43. package/dist/ui/assets/provider-store-access-ChDYzXK5.js +1 -0
  44. package/dist/ui/assets/{providers-BHEqlrX_.js → providers-CItOfmVP.js} +1 -1
  45. package/dist/ui/assets/{proxied-fetch-Czuq7h2B.js → proxied-fetch-CikvmA0_.js} +1 -1
  46. package/dist/ui/assets/{remote-terminal-view-DkDiLkBM.js → remote-terminal-view-Bmb-n3nX.js} +1 -1
  47. package/dist/ui/assets/{store-DFdPyEaa.js → store-C5Q-ke5c.js} +1 -1
  48. package/dist/ui/assets/{sudo-CDWHE-XG.js → sudo-DTDuysV-.js} +1 -1
  49. package/dist/ui/assets/{tray-leave-runtime-CedcHQX5.js → tray-leave-runtime-B0jyMysL.js} +1 -1
  50. package/dist/ui/assets/{upgrade-detection-Bxb3OurB.js → upgrade-detection-B6CdWVNb.js} +1 -1
  51. package/dist/ui/assets/{xai-grok-CaWgmHbx.js → xai-grok-BDmmLGwh.js} +1 -1
  52. package/dist/ui/assets/{xai-grok-BrDEN2gc.js → xai-grok-BHy3Nqkh.js} +1 -1
  53. package/dist/ui/electron-overlay-entry.js +127 -10
  54. package/dist/ui/index.html +1 -1
  55. package/dist/ui/packages/webapp/index.html +1 -1
  56. package/package.json +1 -1
  57. package/dist/ui/assets/cost-command-JwQpMwfk.js +0 -1
  58. package/dist/ui/assets/lick-ws-bridge-X9yJaxZX.js +0 -1
  59. package/dist/ui/assets/provider-store-access-DumMYBkr.js +0 -1
  60. package/dist/ui/assets/provider-store-access-k0kSINjn.js +0 -1
@@ -175,16 +175,10 @@ export async function launchElectronApp(options) {
175
175
  // Theme detection — screenshot-based luminance analysis
176
176
  // ---------------------------------------------------------------------------
177
177
  /**
178
- * Decode a base64 PNG into raw RGBA pixel data by parsing chunks and inflating.
179
- * Returns { width, height, pixels } where pixels is a Buffer of RGBA bytes.
178
+ * Parse the IHDR/IDAT/IEND chunks of a PNG buffer (signature already validated).
179
+ * Other chunk types are intentionally skipped.
180
180
  */
181
- export function decodePngPixels(base64Data) {
182
- const buf = Buffer.from(base64Data, 'base64');
183
- // Validate PNG signature
184
- const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
185
- if (buf.subarray(0, 8).compare(PNG_SIGNATURE) !== 0) {
186
- throw new Error('Not a valid PNG');
187
- }
181
+ function parsePngChunks(buf) {
188
182
  let width = 0;
189
183
  let height = 0;
190
184
  let bitDepth = 0;
@@ -209,6 +203,52 @@ export function decodePngPixels(base64Data) {
209
203
  }
210
204
  offset += 12 + chunkLength; // 4 (length) + 4 (type) + data + 4 (CRC)
211
205
  }
206
+ return { width, height, bitDepth, colorType, idatChunks };
207
+ }
208
+ /**
209
+ * Apply a single PNG row filter in place. Filter 0 (None) is a no-op so this
210
+ * helper is not called for it. See the PNG spec §9 Filtering for details.
211
+ */
212
+ function applyPngRowFilter(row, prevRow, filter, bytesPerPixel, rowBytes) {
213
+ for (let i = 0; i < rowBytes; i++) {
214
+ const a = i >= bytesPerPixel ? row[i - bytesPerPixel] : 0;
215
+ const b = prevRow[i];
216
+ const c = i >= bytesPerPixel ? prevRow[i - bytesPerPixel] : 0;
217
+ switch (filter) {
218
+ case 1: // Sub
219
+ row[i] = (row[i] + a) & 0xff;
220
+ break;
221
+ case 2: // Up
222
+ row[i] = (row[i] + b) & 0xff;
223
+ break;
224
+ case 3: // Average
225
+ row[i] = (row[i] + ((a + b) >>> 1)) & 0xff;
226
+ break;
227
+ case 4: {
228
+ // Paeth
229
+ const p = a + b - c;
230
+ const pa = Math.abs(p - a);
231
+ const pb = Math.abs(p - b);
232
+ const pc = Math.abs(p - c);
233
+ row[i] = (row[i] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff;
234
+ break;
235
+ }
236
+ // case 0: None — no transformation needed
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * Decode a base64 PNG into raw RGBA pixel data by parsing chunks and inflating.
242
+ * Returns { width, height, pixels } where pixels is a Buffer of RGBA bytes.
243
+ */
244
+ export function decodePngPixels(base64Data) {
245
+ const buf = Buffer.from(base64Data, 'base64');
246
+ // Validate PNG signature
247
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
248
+ if (buf.subarray(0, 8).compare(PNG_SIGNATURE) !== 0) {
249
+ throw new Error('Not a valid PNG');
250
+ }
251
+ const { width, height, bitDepth, colorType, idatChunks } = parsePngChunks(buf);
212
252
  if (width === 0 || height === 0)
213
253
  throw new Error('Missing IHDR chunk');
214
254
  if (bitDepth !== 8)
@@ -227,33 +267,7 @@ export function decodePngPixels(base64Data) {
227
267
  const rowStart = y * (1 + rowBytes);
228
268
  const filter = inflated[rowStart];
229
269
  const row = Buffer.from(inflated.subarray(rowStart + 1, rowStart + 1 + rowBytes));
230
- // Apply PNG row filters
231
- for (let i = 0; i < rowBytes; i++) {
232
- const a = i >= bytesPerPixel ? row[i - bytesPerPixel] : 0;
233
- const b = prevRow[i];
234
- const c = i >= bytesPerPixel ? prevRow[i - bytesPerPixel] : 0;
235
- switch (filter) {
236
- case 1: // Sub
237
- row[i] = (row[i] + a) & 0xff;
238
- break;
239
- case 2: // Up
240
- row[i] = (row[i] + b) & 0xff;
241
- break;
242
- case 3: // Average
243
- row[i] = (row[i] + ((a + b) >>> 1)) & 0xff;
244
- break;
245
- case 4: {
246
- // Paeth
247
- const p = a + b - c;
248
- const pa = Math.abs(p - a);
249
- const pb = Math.abs(p - b);
250
- const pc = Math.abs(p - c);
251
- row[i] = (row[i] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff;
252
- break;
253
- }
254
- // case 0: None — no transformation needed
255
- }
256
- }
270
+ applyPngRowFilter(row, prevRow, filter, bytesPerPixel, rowBytes);
257
271
  for (let x = 0; x < width; x++) {
258
272
  const srcIdx = x * bytesPerPixel;
259
273
  const dstIdx = (y * width + x) * 4;
@@ -352,16 +366,81 @@ async function loadElectronOverlayBundleSource(options) {
352
366
  }
353
367
  return await readFile(getElectronOverlayEntryDistPath(options.projectRoot), 'utf8');
354
368
  }
369
+ /**
370
+ * Resolve the `Fetch.enable` origin pattern for CSP-bypass escalation. Mirrors
371
+ * swift-server's `OverlayTargetSession.fetchProxyOrigin` (Wave 5): prefer the
372
+ * parent page's http(s) origin so interception is byte-for-byte the same as
373
+ * before, but for `file://` (or other no-http-origin) targets fall back to the
374
+ * overlay iframe's own `http://localhost:<servePort>` origin — that is what
375
+ * actually needs unblocking when the parent is a local file.
376
+ */
377
+ export function resolveFetchProxyOrigin(targetUrl, servePort) {
378
+ try {
379
+ const url = new URL(targetUrl);
380
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
381
+ return url.origin;
382
+ }
383
+ }
384
+ catch {
385
+ // Fall through to the localhost fallback below.
386
+ }
387
+ return `http://localhost:${servePort}`;
388
+ }
389
+ /**
390
+ * Translate a Node http(s) response's headers into the array shape required by
391
+ * `Fetch.fulfillRequest`, stripping CSP and other hop-by-hop headers that are
392
+ * invalid in fulfill responses and rewriting `content-length` to match the
393
+ * actually-buffered body length. Returns whether any CSP header was stripped
394
+ * so the caller can log it.
395
+ */
396
+ function buildFulfillResponseHeaders(rawHeaders, contentLength) {
397
+ const HOP_BY_HOP = new Set([
398
+ 'content-security-policy',
399
+ 'content-security-policy-report-only',
400
+ 'transfer-encoding',
401
+ 'connection',
402
+ 'keep-alive',
403
+ ]);
404
+ const responseHeaders = [];
405
+ let strippedCSP = false;
406
+ for (const [name, value] of Object.entries(rawHeaders)) {
407
+ const lower = name.toLowerCase();
408
+ if (lower.includes('content-security-policy')) {
409
+ strippedCSP = true;
410
+ continue;
411
+ }
412
+ if (HOP_BY_HOP.has(lower))
413
+ continue;
414
+ // Update content-length to match actual body size
415
+ if (lower === 'content-length') {
416
+ responseHeaders.push({ name, value: String(contentLength) });
417
+ continue;
418
+ }
419
+ if (Array.isArray(value)) {
420
+ value.forEach((v) => {
421
+ responseHeaders.push({ name, value: v });
422
+ });
423
+ }
424
+ else if (value) {
425
+ responseHeaders.push({ name, value });
426
+ }
427
+ }
428
+ return { responseHeaders, strippedCSP };
429
+ }
355
430
  export class ElectronOverlayInjector {
356
431
  cdpPort;
432
+ servePort;
357
433
  bootstrapScript;
434
+ probeDelayMs;
358
435
  connections = new Map();
359
436
  cspBypassedTargets = new Set();
360
437
  syncTimer = null;
361
438
  syncing = false;
362
- constructor(cdpPort, bootstrapScript) {
439
+ constructor(cdpPort, servePort, bootstrapScript, probeDelayMs = 1500) {
363
440
  this.cdpPort = cdpPort;
441
+ this.servePort = servePort;
364
442
  this.bootstrapScript = bootstrapScript;
443
+ this.probeDelayMs = probeDelayMs;
365
444
  }
366
445
  static async create(options) {
367
446
  const bundleSource = await loadElectronOverlayBundleSource(options);
@@ -369,7 +448,39 @@ export class ElectronOverlayInjector {
369
448
  bundleSource,
370
449
  appUrl: buildElectronOverlayAppUrl(getElectronServeOrigin(options.servePort)),
371
450
  });
372
- return new ElectronOverlayInjector(options.cdpPort, bootstrapScript);
451
+ return new ElectronOverlayInjector(options.cdpPort, options.servePort, bootstrapScript);
452
+ }
453
+ /**
454
+ * Test-only factory: skips bundle loading and lets tests drive the per-target
455
+ * connect flow directly with a controllable probe delay. Mirrors swift-server's
456
+ * `_testing_*` hooks on `ElectronOverlayInjector`.
457
+ */
458
+ static _createForTesting(options) {
459
+ return new ElectronOverlayInjector(options.cdpPort ?? 9223, options.servePort, options.bootstrapScript ?? '/* test-noop */', options.probeDelayMs ?? 1500);
460
+ }
461
+ /** Test-only: drive the per-target connect flow without going through `start`. */
462
+ _testingConnectToTarget(target) {
463
+ this.connectToTarget(target);
464
+ }
465
+ /** Test-only: seed the per-target "already bypassed" guard. */
466
+ _testingSeedBypassedTarget(url) {
467
+ this.cspBypassedTargets.add(url);
468
+ }
469
+ /** Test-only: snapshot the per-target "already bypassed" guard set. */
470
+ _testingBypassedTargets() {
471
+ return new Set(this.cspBypassedTargets);
472
+ }
473
+ /** Test-only: close any sockets opened by `_testingConnectToTarget`. */
474
+ _testingCloseConnections() {
475
+ for (const connection of this.connections.values()) {
476
+ try {
477
+ connection.close();
478
+ }
479
+ catch {
480
+ // Ignore connection cleanup failures.
481
+ }
482
+ }
483
+ this.connections.clear();
373
484
  }
374
485
  async start() {
375
486
  await this.syncTargets();
@@ -481,6 +592,199 @@ export class ElectronOverlayInjector {
481
592
  ws.on('message', onMessage);
482
593
  });
483
594
  }
595
+ /**
596
+ * Build a script that sets the SLICC theme preference in localStorage to
597
+ * match the target app's detected theme, then runs the bootstrap.
598
+ */
599
+ buildThemedBootstrap(theme) {
600
+ const themeScript = `try{localStorage.setItem('slicc-theme',${JSON.stringify(theme)})}catch(e){}`;
601
+ return `${themeScript}\n${this.bootstrapScript}`;
602
+ }
603
+ /**
604
+ * Handle the initial CDP `ws.on('open', ...)` event for a target: enable
605
+ * Runtime/Page, set CSP bypass, detect theme, inject the overlay, and (on a
606
+ * first connect) probe whether the overlay iframe actually loaded — falling
607
+ * back to a CSP-bypass reload by setting `state.pendingReload` and
608
+ * `state.pendingCspEscalation` for the message handler to continue from.
609
+ *
610
+ * Mutating flow flags (`pendingReload`, `pendingCspEscalation`,
611
+ * `fetchProxyActive`) live on the shared `state` object so this helper
612
+ * preserves the original closure-driven control flow exactly.
613
+ */
614
+ handleSocketOpen(ws, send, target, state) {
615
+ const alreadyBypassed = this.cspBypassedTargets.has(target.url);
616
+ console.log(`[electron-float] Connected to target, bypassed=${alreadyBypassed}, url=${target.url}`);
617
+ send('Runtime.enable');
618
+ send('Page.enable');
619
+ // Set CSP bypass — affects future resource loads on the current page
620
+ send('Page.setBypassCSP', { enabled: true });
621
+ if (alreadyBypassed) {
622
+ // Already reloaded with CSP bypass previously — detect theme and inject
623
+ console.log(`[electron-float] Detecting theme and injecting overlay (CSP already bypassed)...`);
624
+ void detectAppThemeFromScreenshot(ws, send).then((theme) => {
625
+ if (ws.readyState !== WebSocket.OPEN)
626
+ return;
627
+ send('Runtime.evaluate', {
628
+ expression: this.buildThemedBootstrap(theme),
629
+ awaitPromise: false,
630
+ });
631
+ });
632
+ return;
633
+ }
634
+ // First connection to this target URL: detect theme, then inject overlay.
635
+ // After injection, probe whether the iframe loaded; if CSP blocked it, fall
636
+ // back to reload+proxy. We probe/escalate regardless of URL scheme — file://
637
+ // (and app://-style local) Electron renderers can still ship a meta CSP
638
+ // (e.g. AEM Desktop's `default-src 'self'`) that blocks the overlay iframe.
639
+ console.log(`[electron-float] Detecting theme before first overlay injection...`);
640
+ void detectAppThemeFromScreenshot(ws, send).then((theme) => {
641
+ if (ws.readyState !== WebSocket.OPEN)
642
+ return;
643
+ console.log(`[electron-float] Injecting overlay (first attempt, theme=${theme})...`);
644
+ send('Runtime.evaluate', {
645
+ expression: this.buildThemedBootstrap(theme),
646
+ awaitPromise: false,
647
+ });
648
+ // After a short delay, probe whether the overlay iframe loaded.
649
+ // If CSP blocked it, reload the page so Page.setBypassCSP takes effect.
650
+ // If that still doesn't work, escalate to the Fetch proxy.
651
+ setTimeout(async () => {
652
+ if (ws.readyState !== WebSocket.OPEN)
653
+ return;
654
+ const loaded = await this.probeOverlayIframeLoaded(ws, send);
655
+ if (loaded) {
656
+ console.log(`[electron-float] Overlay iframe loaded successfully — no CSP reload needed`);
657
+ this.cspBypassedTargets.add(target.url);
658
+ return;
659
+ }
660
+ // Phase 2: Page.setBypassCSP was already set — a simple reload should
661
+ // make the browser ignore CSP headers on the fresh navigation.
662
+ // Deliberately do NOT recordBypassed yet — if the CDP session
663
+ // disconnects mid-reload (AEM Desktop's bootstrap recreates the
664
+ // execution context, which closes our WS), the next reconnect
665
+ // needs to re-run the reload path. Only record once the post-reload
666
+ // probe confirms the iframe loaded. Mirrors swift-server d1c9f14d
667
+ // (`shouldRecordBypassedAfter(probeAction:)` returns false for
668
+ // `.reloadWithBypass`).
669
+ console.log(`[electron-float] Overlay iframe blocked by CSP, reloading with bypass: ${target.url}`);
670
+ state.pendingReload = true;
671
+ state.pendingCspEscalation = true;
672
+ send('Page.reload', { ignoreCache: true });
673
+ }, this.probeDelayMs);
674
+ });
675
+ }
676
+ /**
677
+ * Handle `Page.loadEventFired` after a CSP-bypass reload: re-inject the
678
+ * themed overlay, then (if this load came from the simple-reload path) probe
679
+ * the iframe again and, if still blocked, escalate to the Fetch HTTP proxy
680
+ * which strips CSP from the document response. The proxy reload also sets
681
+ * `pendingReload` again so the next `loadEventFired` re-injects on top of
682
+ * the stripped response.
683
+ */
684
+ handlePageLoadAfterReload(ws, send, target, state) {
685
+ state.pendingReload = false;
686
+ console.log(`[electron-float] Page loaded after CSP reload, detecting theme and injecting overlay...`);
687
+ if (ws.readyState !== WebSocket.OPEN)
688
+ return;
689
+ void detectAppThemeFromScreenshot(ws, send).then((theme) => {
690
+ if (ws.readyState !== WebSocket.OPEN)
691
+ return;
692
+ send('Runtime.evaluate', {
693
+ expression: this.buildThemedBootstrap(theme),
694
+ awaitPromise: false,
695
+ });
696
+ });
697
+ // If this was a simple reload (no proxy), check if the iframe loads now.
698
+ // If it still doesn't, escalate to the Fetch proxy as a last resort.
699
+ if (state.pendingCspEscalation) {
700
+ state.pendingCspEscalation = false;
701
+ setTimeout(async () => {
702
+ if (ws.readyState !== WebSocket.OPEN)
703
+ return;
704
+ const loaded = await this.probeOverlayIframeLoaded(ws, send);
705
+ if (loaded) {
706
+ console.log(`[electron-float] Overlay iframe loaded after CSP reload — no proxy needed`);
707
+ this.cspBypassedTargets.add(target.url);
708
+ return;
709
+ }
710
+ const fetchOrigin = resolveFetchProxyOrigin(target.url, this.servePort);
711
+ console.log(`[electron-float] CSP reload insufficient, escalating to Fetch proxy: target=${target.url} origin=${fetchOrigin}`);
712
+ state.fetchProxyActive = true;
713
+ send('Fetch.enable', {
714
+ patterns: [{ urlPattern: `${fetchOrigin}/*`, requestStage: 'Request' }],
715
+ });
716
+ state.pendingReload = true;
717
+ send('Page.reload', { ignoreCache: true });
718
+ }, this.probeDelayMs);
719
+ }
720
+ }
721
+ /**
722
+ * Handle a single `Fetch.requestPaused` event under the active Fetch proxy:
723
+ * pass non-HTML requests straight through with `Fetch.continueRequest`, and
724
+ * proxy HTML document requests through Node http/https so the response can
725
+ * be returned via `Fetch.fulfillRequest` with CSP and hop-by-hop headers
726
+ * stripped. `Fetch.fulfillRequest` is intentionally fire-and-forget — there
727
+ * is no CDP reply for fulfill, and the response body is the document body.
728
+ */
729
+ handleFetchRequestPaused(ws, send, msg) {
730
+ const requestId = msg.params?.requestId;
731
+ if (!requestId) {
732
+ console.warn('[electron-float] Fetch.requestPaused without requestId, skipping');
733
+ return;
734
+ }
735
+ const request = (msg.params?.request ?? {});
736
+ const url = request.url || '';
737
+ const method = request.method || 'GET';
738
+ const requestHeaders = request.headers || {};
739
+ const postData = request.postData;
740
+ // Only proxy HTML document requests (Accept header contains text/html)
741
+ const acceptHeader = requestHeaders['Accept'] || requestHeaders['accept'] || '';
742
+ if (!acceptHeader.includes('text/html')) {
743
+ send('Fetch.continueRequest', { requestId });
744
+ return;
745
+ }
746
+ console.log(`[electron-float] Proxying request to strip CSP: ${url.substring(0, 60)}`);
747
+ // Make the request ourselves using Node.js http/https
748
+ const parsedUrl = new URL(url);
749
+ const transport = parsedUrl.protocol === 'https:' ? https : http;
750
+ const options = {
751
+ hostname: parsedUrl.hostname,
752
+ port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
753
+ path: parsedUrl.pathname + parsedUrl.search,
754
+ method: method,
755
+ headers: requestHeaders,
756
+ };
757
+ const proxyReq = transport.request(options, (proxyRes) => {
758
+ const bodyChunks = [];
759
+ proxyRes.on('data', (chunk) => bodyChunks.push(chunk));
760
+ proxyRes.on('end', () => {
761
+ if (ws.readyState !== WebSocket.OPEN)
762
+ return;
763
+ const fullBody = Buffer.concat(bodyChunks);
764
+ const { responseHeaders, strippedCSP } = buildFulfillResponseHeaders(proxyRes.headers, fullBody.length);
765
+ if (strippedCSP) {
766
+ console.log(`[electron-float] Stripped CSP from: ${url.substring(0, 60)}`);
767
+ }
768
+ send('Fetch.fulfillRequest', {
769
+ requestId,
770
+ responseCode: proxyRes.statusCode || 200,
771
+ responseHeaders,
772
+ body: fullBody.toString('base64'),
773
+ });
774
+ });
775
+ });
776
+ proxyReq.on('error', (err) => {
777
+ console.error(`[electron-float] Proxy request failed for ${url.substring(0, 60)}:`, err.message);
778
+ if (ws.readyState === WebSocket.OPEN) {
779
+ send('Fetch.failRequest', { requestId, errorReason: 'Failed' });
780
+ }
781
+ });
782
+ // Forward request body if present (for POST/PUT requests)
783
+ if (postData) {
784
+ proxyReq.write(postData);
785
+ }
786
+ proxyReq.end();
787
+ }
484
788
  connectToTarget(target) {
485
789
  const targetId = target.webSocketDebuggerUrl;
486
790
  const ws = new WebSocket(targetId);
@@ -491,204 +795,24 @@ export class ElectronOverlayInjector {
491
795
  ws.send(JSON.stringify({ id, method, params }));
492
796
  return id;
493
797
  };
494
- const cspBypassedTargets = this.cspBypassedTargets;
495
- const bootstrapScript = this.bootstrapScript;
496
- let pendingReload = false;
497
- let pendingCspEscalation = false;
498
- let fetchProxyActive = false;
499
- /**
500
- * Build a script that sets the SLICC theme preference in localStorage
501
- * to match the target app's detected theme, then runs the bootstrap.
502
- */
503
- const buildThemedBootstrap = (theme) => {
504
- const themeScript = `try{localStorage.setItem('slicc-theme',${JSON.stringify(theme)})}catch(e){}`;
505
- return `${themeScript}\n${bootstrapScript}`;
798
+ const state = {
799
+ pendingReload: false,
800
+ pendingCspEscalation: false,
801
+ fetchProxyActive: false,
506
802
  };
507
803
  ws.on('open', () => {
508
- const isWebContent = target.url.startsWith('https://');
509
- const alreadyBypassed = cspBypassedTargets.has(target.url);
510
- console.log(`[electron-float] Connected to target, web=${isWebContent}, bypassed=${alreadyBypassed}, url=${target.url}`);
511
- send('Runtime.enable');
512
- send('Page.enable');
513
- // Set CSP bypass — affects future resource loads on the current page
514
- send('Page.setBypassCSP', { enabled: true });
515
- if (alreadyBypassed) {
516
- // Already reloaded with CSP bypass previously — detect theme and inject
517
- console.log(`[electron-float] Detecting theme and injecting overlay (CSP already bypassed)...`);
518
- void detectAppThemeFromScreenshot(ws, send).then((theme) => {
519
- if (ws.readyState !== WebSocket.OPEN)
520
- return;
521
- send('Runtime.evaluate', {
522
- expression: buildThemedBootstrap(theme),
523
- awaitPromise: false,
524
- });
525
- });
526
- return;
527
- }
528
- // First connection to this target URL: detect theme, then inject overlay.
529
- // After injection, check if the iframe loaded. If CSP blocked it, fall back to reload+proxy.
530
- console.log(`[electron-float] Detecting theme before first overlay injection...`);
531
- void detectAppThemeFromScreenshot(ws, send).then((theme) => {
532
- if (ws.readyState !== WebSocket.OPEN)
533
- return;
534
- console.log(`[electron-float] Injecting overlay (first attempt, theme=${theme})...`);
535
- send('Runtime.evaluate', { expression: buildThemedBootstrap(theme), awaitPromise: false });
536
- if (!isWebContent) {
537
- // Local content (file://, app protocol) — CSP is not an issue
538
- return;
539
- }
540
- // After a short delay, probe whether the overlay iframe loaded.
541
- // If CSP blocked it, reload the page so Page.setBypassCSP takes effect.
542
- // If that still doesn't work, escalate to the Fetch proxy.
543
- setTimeout(async () => {
544
- if (ws.readyState !== WebSocket.OPEN)
545
- return;
546
- const loaded = await this.probeOverlayIframeLoaded(ws, send);
547
- if (loaded) {
548
- console.log(`[electron-float] Overlay iframe loaded successfully — no CSP reload needed`);
549
- cspBypassedTargets.add(target.url);
550
- return;
551
- }
552
- // Phase 2: Page.setBypassCSP was already set — a simple reload should
553
- // make the browser ignore CSP headers on the fresh navigation.
554
- console.log(`[electron-float] Overlay iframe blocked by CSP, reloading with bypass: ${target.url}`);
555
- cspBypassedTargets.add(target.url);
556
- pendingReload = true;
557
- pendingCspEscalation = true;
558
- send('Page.reload', { ignoreCache: true });
559
- }, 1500);
560
- });
804
+ this.handleSocketOpen(ws, send, target, state);
561
805
  });
562
806
  // Handle CDP events: lifecycle events and Fetch interception
563
807
  ws.on('message', (data) => {
564
808
  try {
565
809
  const msg = JSON.parse(data.toString());
566
810
  // Inject overlay after page load completes (after CSP-bypass reload)
567
- if (msg.method === 'Page.loadEventFired' && pendingReload) {
568
- pendingReload = false;
569
- console.log(`[electron-float] Page loaded after CSP reload, detecting theme and injecting overlay...`);
570
- if (ws.readyState !== WebSocket.OPEN)
571
- return;
572
- void detectAppThemeFromScreenshot(ws, send).then((theme) => {
573
- if (ws.readyState !== WebSocket.OPEN)
574
- return;
575
- send('Runtime.evaluate', {
576
- expression: buildThemedBootstrap(theme),
577
- awaitPromise: false,
578
- });
579
- });
580
- // If this was a simple reload (no proxy), check if the iframe loads now.
581
- // If it still doesn't, escalate to the Fetch proxy as a last resort.
582
- if (pendingCspEscalation) {
583
- pendingCspEscalation = false;
584
- setTimeout(async () => {
585
- if (ws.readyState !== WebSocket.OPEN)
586
- return;
587
- const loaded = await this.probeOverlayIframeLoaded(ws, send);
588
- if (loaded) {
589
- console.log(`[electron-float] Overlay iframe loaded after CSP reload — no proxy needed`);
590
- return;
591
- }
592
- console.log(`[electron-float] CSP reload insufficient, escalating to Fetch proxy: ${target.url}`);
593
- fetchProxyActive = true;
594
- const urlOrigin = new URL(target.url).origin;
595
- send('Fetch.enable', {
596
- patterns: [{ urlPattern: `${urlOrigin}/*`, requestStage: 'Request' }],
597
- });
598
- pendingReload = true;
599
- send('Page.reload', { ignoreCache: true });
600
- }, 1500);
601
- }
811
+ if (msg.method === 'Page.loadEventFired' && state.pendingReload) {
812
+ this.handlePageLoadAfterReload(ws, send, target, state);
602
813
  }
603
- if (msg.method === 'Fetch.requestPaused' && fetchProxyActive) {
604
- const requestId = msg.params?.requestId;
605
- if (!requestId) {
606
- console.warn('[electron-float] Fetch.requestPaused without requestId, skipping');
607
- return;
608
- }
609
- const url = msg.params?.request?.url || '';
610
- const method = msg.params?.request?.method || 'GET';
611
- const requestHeaders = msg.params?.request?.headers || {};
612
- const postData = msg.params?.request?.postData;
613
- // Only proxy HTML document requests (Accept header contains text/html)
614
- const acceptHeader = requestHeaders['Accept'] || requestHeaders['accept'] || '';
615
- if (!acceptHeader.includes('text/html')) {
616
- send('Fetch.continueRequest', { requestId });
617
- return;
618
- }
619
- console.log(`[electron-float] Proxying request to strip CSP: ${url.substring(0, 60)}`);
620
- // Make the request ourselves using Node.js http/https
621
- const parsedUrl = new URL(url);
622
- const transport = parsedUrl.protocol === 'https:' ? https : http;
623
- const options = {
624
- hostname: parsedUrl.hostname,
625
- port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
626
- path: parsedUrl.pathname + parsedUrl.search,
627
- method: method,
628
- headers: requestHeaders,
629
- };
630
- const proxyReq = transport.request(options, (proxyRes) => {
631
- const bodyChunks = [];
632
- proxyRes.on('data', (chunk) => bodyChunks.push(chunk));
633
- proxyRes.on('end', () => {
634
- if (ws.readyState !== WebSocket.OPEN)
635
- return;
636
- const fullBody = Buffer.concat(bodyChunks);
637
- // Build response headers, stripping CSP and hop-by-hop headers
638
- // that are invalid in Fetch.fulfillRequest responses
639
- const HOP_BY_HOP = new Set([
640
- 'content-security-policy',
641
- 'content-security-policy-report-only',
642
- 'transfer-encoding',
643
- 'connection',
644
- 'keep-alive',
645
- ]);
646
- const responseHeaders = [];
647
- let strippedCSP = false;
648
- for (const [name, value] of Object.entries(proxyRes.headers)) {
649
- const lower = name.toLowerCase();
650
- if (lower.includes('content-security-policy')) {
651
- strippedCSP = true;
652
- continue;
653
- }
654
- if (HOP_BY_HOP.has(lower))
655
- continue;
656
- // Update content-length to match actual body size
657
- if (lower === 'content-length') {
658
- responseHeaders.push({ name, value: String(fullBody.length) });
659
- continue;
660
- }
661
- if (Array.isArray(value)) {
662
- value.forEach((v) => {
663
- responseHeaders.push({ name, value: v });
664
- });
665
- }
666
- else if (value) {
667
- responseHeaders.push({ name, value });
668
- }
669
- }
670
- if (strippedCSP) {
671
- console.log(`[electron-float] Stripped CSP from: ${url.substring(0, 60)}`);
672
- }
673
- send('Fetch.fulfillRequest', {
674
- requestId,
675
- responseCode: proxyRes.statusCode || 200,
676
- responseHeaders,
677
- body: fullBody.toString('base64'),
678
- });
679
- });
680
- });
681
- proxyReq.on('error', (err) => {
682
- console.error(`[electron-float] Proxy request failed for ${url.substring(0, 60)}:`, err.message);
683
- if (ws.readyState === WebSocket.OPEN) {
684
- send('Fetch.failRequest', { requestId, errorReason: 'Failed' });
685
- }
686
- });
687
- // Forward request body if present (for POST/PUT requests)
688
- if (postData) {
689
- proxyReq.write(postData);
690
- }
691
- proxyReq.end();
814
+ if (msg.method === 'Fetch.requestPaused' && state.fetchProxyActive) {
815
+ this.handleFetchRequestPaused(ws, send, msg);
692
816
  }
693
817
  }
694
818
  catch {