import { BRIDGE_ROLE_FOLLOWER, BRIDGE_ROLE_LEADER, BRIDGE_ROLE_QUERY_PARAM } from './_shared/index.js'; import { type ChildProcess } from 'child_process'; import { type ElectronInspectableTarget } from './electron-runtime.js'; export { BRIDGE_ROLE_FOLLOWER, BRIDGE_ROLE_LEADER, BRIDGE_ROLE_QUERY_PARAM }; /** * Thin-bridge coordinates for the Electron overlay. The injected overlay * always loads from a hosted launcher (`hostedLeaderOrigin`, defaulting to * production `https://www.sliccy.ai`) and dials back to the local `/cdp` * WebSocket using the per-process bridge token. This is the only overlay * path — the legacy bundled-UI overlay served from the local serve port * was retired. */ export interface ThinBridgeConfig { hostedLeaderOrigin: string; bridgeWsUrl: string; bridgeToken: string; } export type OverlayRole = typeof BRIDGE_ROLE_LEADER | typeof BRIDGE_ROLE_FOLLOWER; /** * Build the thin-bridge config for the Electron overlay injector. The * hosted-leader origin defaults to production `https://www.sliccy.ai` * (overridable via `SLICC_HOSTED_LEADER_ORIGIN` / `WORKER_BASE_URL`), so * the only genuinely unresolvable case is a missing per-process bridge * token — in which case this returns `null` and the caller fails fast * rather than falling back to a (now-retired) bundled overlay. */ export declare function resolveOverlayThinBridge(env: Record, bridgeToken: string | null, servePort: number): ThinBridgeConfig | null; export interface ThinOverlayUrlOptions extends ThinBridgeConfig { role: OverlayRole; activeTab?: string; /** * Explicit tray intent for the overlay URL, three-valued: * * - a normalized join URL (`--join`) → `tray=`: the hosted webapp * resolves it via `resolveFollowerJoinUrl` and attaches to the running * leader as a tray FOLLOWER instead of minting its own tray — omitting * it was the bug that made every egress-allowed Electron app silently * become a second leader. * - the EMPTY string → `tray=` (empty): explicit "no tray" intent. The * webapp treats any present `tray` param as explicit intent, which * blocks `resolveFollowerJoinUrl`'s localStorage fallback — without it, * the join URL the leader tab persists into the shared sliccy.ai * storage would re-enter through that fallback and boot every * auto-follow tab as ANOTHER tray follower (N followers for one * multi-window app). * - absent/null → no `tray` param at all; storage-based re-follow stays * possible. Reserved for the SLICC Electron float's own window * (`electron-main.ts`), whose stored-tray reattach is intentional. */ trayJoinUrl?: string | null; } /** * Build the hosted launcher URL for an overlay injection. Mirrors the * standalone Path A launch-URL shape (`bridge`, `bridgeToken` query * params) with two Electron-specific additions: a `role` param that pins * the first injected tab as the leader and marks every subsequent tab as * an auto-follow follower, and — on a `--join` launch — the same * `tray=` param the Chrome join path emits so the pinned tab * attaches to the running leader as a tray follower. */ export declare function buildThinOverlayAppUrl(opts: ThinOverlayUrlOptions): string; /** * Resolve the hosted leader origin Chrome / Electron should open in thin * mode. Prefers explicit overrides (`SLICC_HOSTED_LEADER_ORIGIN`, then * `WORKER_BASE_URL`) so dev can point at staging; defaults to production * `https://www.sliccy.ai`. Trailing slashes are stripped so callers can * safely concatenate paths. */ export declare function resolveHostedLeaderOrigin(env?: Record): string; interface RunningProcessInfo { pid: number; commandLine: string; executablePath: string | null; } export declare function findMatchingElectronAppPids(runningProcesses: RunningProcessInfo[], processMatchPatterns: string[], currentPid?: number): number[]; export declare class ElectronAppAlreadyRunningError extends Error { constructor(message: string); } export declare function launchElectronApp(options: { appPath: string; cdpPort: number; kill: boolean; platform?: NodeJS.Platform; }): Promise<{ child: ChildProcess; displayName: string; }>; /** * Decode a base64 PNG into raw RGBA pixel data by parsing chunks and inflating. * Returns { width, height, pixels } where pixels is a Buffer of RGBA bytes. */ export declare function decodePngPixels(base64Data: string): { width: number; height: number; pixels: Buffer; }; /** * Compute the average perceived luminance (0–255) from RGBA pixel data, * sampling a grid of pixels for performance. */ export declare function computeAverageLuminance(pixels: Buffer, width: number, height: number, sampleStep?: number): number; /** * Resolve the `Fetch.enable` origin pattern for CSP-bypass escalation. Mirrors * swift-server's `OverlayTargetSession.fetchProxyOrigin` (Wave 5): prefer the * parent page's http(s) origin so interception is byte-for-byte the same as * before, but for `file://` (or other no-http-origin) targets fall back to the * overlay iframe's own `http://localhost:` origin — that is what * actually needs unblocking when the parent is a local file. */ export declare function resolveFetchProxyOrigin(targetUrl: string, servePort: number): string; /** * Pre-built bootstrap scripts for thin-mode injection — one per overlay * role. The injector picks `leader` for the first injected target and * `follower` for every subsequent target. */ export interface ThinBootstrapSet { leader: string; follower: string; /** * Status-only overlay bootstrap: injects the launcher with NO app-url (no * iframe) and a message explaining the app blocks the embedded panel. Used * for egress-blocked apps (e.g. Signal) so the user sees the launcher + a * clear note instead of a silent blank panel. */ status: string; } /** * Empty-viewport message shown by the status-only overlay for an app that * denies the overlay iframe's network egress. Kept in sync with swift-server's * `overlayStatusMessageEgressBlocked`. */ 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."; /** * CDP `Network.loadingFailed` `errorText` values that mean the *app itself* * denied the overlay's document request at the network layer — not a CSP block. * Signal (and other locked-down Electron apps) proxy all renderer network * through their main process and deny external requests with * `net::ERR_ACCESS_DENIED`, BENEATH the layer where `Page.setBypassCSP` or the * CDP Fetch proxy operate — so the reload/Fetch escalation cannot help and must * be skipped. Unlike a CSP block, the thrown probe (`OVERLAY_LOADED_PROBE_EXPRESSION`) * can't distinguish this from a successful cross-origin load (both throw), so we * detect it authoritatively from the failed document request instead. */ export declare const OVERLAY_EGRESS_BLOCK_ERROR_TEXTS: readonly string[]; /** True when `errorText` is one of {@link OVERLAY_EGRESS_BLOCK_ERROR_TEXTS}. */ export declare function isOverlayEgressBlockError(errorText: string | undefined): boolean; /** * JS probe that reports whether the overlay iframe actually loaded. Walks the * `` host's (open) shadow root to find the iframe * depth-agnostically, then classifies by cross-origin reachability: the * thin-bridge overlay is ALWAYS a different origin (hosted webapp) than the * app document, so a committed cross-origin navigation makes * `iframe.contentWindow.location.href` THROW — that throw is the ONLY success * signal. Any READABLE href (`about:blank`, `''`, or a CSP-blocked swap to * `chrome-error://chromewebdata/`) means the cross-origin nav did NOT commit, * so the overlay did not load and the setBypassCSP escalation must fire. * Returns `'ok'` only from the catch; otherwise `'no-host' / 'no-iframe' / * 'no-src' / 'blank:'`. * * CAVEAT: a *network-layer* block (the app denying the overlay's document * request with `net::ERR_ACCESS_DENIED`) swaps to a CROSS-origin `chrome-error` * page whose href access ALSO throws — indistinguishable here from a real load. * That case is detected authoritatively from `Network.loadingFailed` * (see {@link isOverlayEgressBlockError}); this probe's `'ok'` is overridden by * the connection's `egressBlocked` flag so it is never recorded as loaded. */ 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 })()"; /** * JS probe that reports whether the overlay was *evicted* — i.e. the * `window.__SLICC_ELECTRON_OVERLAY__` marker is still present (the bootstrap * ran at least once on this document) but the `#slicc-electron-overlay-root` * host element is gone, which happens when an SPA framework (React/Vue) * re-renders the DOM root out from under it on an in-page route change. * Returns `'evicted'` only in that exact state so re-injection is gated to the * genuine eviction case and never loops while the host element is still * attached. A full document replacement wipes the marker too, so that case * reports `'ok'` here and is covered by the new-document hook instead. */ export declare const OVERLAY_EVICTED_PROBE_EXPRESSION = "(function() {\n try {\n var hasMarker = typeof window.__SLICC_ELECTRON_OVERLAY__ !== 'undefined';\n var hasRoot = !!document.getElementById('slicc-electron-overlay-root');\n return (hasMarker && !hasRoot) ? 'evicted' : 'ok';\n } catch (e) {\n return 'ok';\n }\n })()"; export declare class ElectronOverlayInjector { private readonly cdpPort; private readonly servePort; /** Thin-mode bootstrap pair — the only overlay path. */ private readonly thinBootstraps; private readonly probeDelayMs; private readonly presenceCheckIntervalMs; private readonly connections; private readonly cspBypassedTargets; /** * Per-process bridge token, present in every overlay app URL. Used to * correlate a `Network.loadingFailed` back to OUR overlay iframe's document * request (vs the app's own frames) when detecting an egress block. */ private readonly bridgeToken; /** * Targets whose overlay iframe was denied at the network layer by the app * itself (e.g. Signal → `net::ERR_ACCESS_DENIED`). The reload/Fetch-proxy * escalation cannot rescue these, so once a target is here we stop escalating * and never record it as CSP-bypassed (which would falsely read as loaded). */ private readonly egressBlockedTargets; /** * URL of the target currently elected as the pinned leader. Cleared by * `syncTargets` when that target disappears so the next injection * re-elects a fresh leader. */ private leaderTargetUrl; /** * Fired ONCE when the app is first detected to block renderer egress, so the * runtime can start the headless CDP-over-CDP follower (`ElectronTrayFollower`) * for this app. The hosted overlay can never load in such apps, so exposing * their CDP to the tray leader is the only way to drive them. */ private readonly onEgressBlocked?; private syncTimer; private syncing; private constructor(); static create(options: { cdpPort: number; servePort: number; projectRoot: string; /** * Thin-bridge coordinates: the overlay loads from the hosted launcher * with bridge-URL + token query params and tabs are split into one * pinned leader and N auto-follow followers. Required — the legacy * bundled overlay path was retired. */ thinBridge: ThinBridgeConfig; /** Start the headless CDP-over-CDP follower when egress-block is detected. */ onEgressBlocked?: (targetUrl: string) => void; /** * Normalized tray join URL (`--join`); rides the LEADER-role overlay URL * only, so the app's pinned first tab attaches to the running leader as * ONE tray follower. In-app auto-follow tabs instead carry an explicitly * EMPTY `tray=` — the leader tab persists the join URL into the shared * sliccy.ai localStorage, and without explicit no-tray intent the * `resolveFollowerJoinUrl` storage fallback would boot every extra * window as ANOTHER tray follower (N followers for one app). */ trayJoinUrl?: string | null; }): Promise; /** * Test-only factory: skips bundle loading and lets tests drive the per-target * connect flow directly with a controllable probe delay. Mirrors swift-server's * `_testing_*` hooks on `ElectronOverlayInjector`. */ static _createForTesting(options: { cdpPort?: number; servePort: number; thinBootstraps?: ThinBootstrapSet; bridgeToken?: string; onEgressBlocked?: (targetUrl: string) => void; probeDelayMs?: number; presenceCheckIntervalMs?: number; }): ElectronOverlayInjector; /** Test-only: snapshot the elected leader target URL (null when no leader). */ _testingLeaderTargetUrl(): string | null; /** Test-only: seed the elected leader (drives the follower-election path). */ _testingSeedLeaderTargetUrl(url: string | null): void; /** Test-only: drive the per-target connect flow without going through `start`. */ _testingConnectToTarget(target: ElectronInspectableTarget): void; /** Test-only: seed the per-target "already bypassed" guard. */ _testingSeedBypassedTarget(url: string): void; /** Test-only: snapshot the per-target "already bypassed" guard set. */ _testingBypassedTargets(): ReadonlySet; /** Test-only: snapshot the set of targets marked egress-blocked. */ _testingEgressBlockedTargets(): ReadonlySet; /** Test-only: seed a target as already egress-blocked. */ _testingSeedEgressBlockedTarget(url: string): void; /** Test-only: drive a single `syncTargets` pass without `start()`'s interval. */ _testingSyncTargets(): Promise; /** Test-only: close any sockets opened by `_testingConnectToTarget`. */ _testingCloseConnections(): void; start(): Promise; stop(): void; private syncTargets; /** * Check if the overlay iframe loaded successfully by evaluating a probe * script. Walks the `` host's (open) shadow root to find the * iframe depth-agnostically and classifies by cross-origin reachability: the * thin-bridge overlay is ALWAYS a different origin than the app document, so * only a THROW on `iframe.contentWindow.location.href` (a committed * cross-origin navigation) counts as loaded. Any readable href — including a * CSP-blocked swap to `chrome-error://chromewebdata/` — means the nav did not * commit, so the Fetch-proxy escalation must still fire. * See {@link OVERLAY_LOADED_PROBE_EXPRESSION}. */ private probeOverlayIframeLoaded; /** * Pick the bootstrap script for `target`, electing the leader on first * use when thin-mode is active. Same target URL ↔ same role across * reconnects so a page that bounces its CDP session stays the leader * (no re-election on transient drops, only on `syncTargets` cleanup). */ private resolveBootstrapForTarget; /** * Build a script that sets the SLICC theme preference in localStorage to * match the target app's detected theme, then runs the bootstrap. The * bootstrap is target-specific (leader vs. follower). */ private buildThemedBootstrap; /** * Wrap the target's role bootstrap in a top-frame guard for use as a * `Page.addScriptToEvaluateOnNewDocument` source. The hook fires in every * frame of a new document, so without the guard the overlay iframe (the * hosted webapp, which also ships `__SLICC_ELECTRON_OVERLAY__`) would re-run * the bootstrap inside itself and recurse. Re-using `resolveBootstrapForTarget` * keeps the re-injected overlay's leader/follower role stable. */ private buildNewDocumentBootstrap; /** * Evaluate {@link OVERLAY_EVICTED_PROBE_EXPRESSION} on the target and resolve * `true` only when the overlay marker is present but the host element is gone * — the SPA-DOM-root eviction case that re-injection must repair. Mirrors * {@link probeOverlayIframeLoaded}'s one-shot message-listener pattern. */ private probeOverlayEvicted; /** * Re-inject the overlay if (and only if) it was evicted from an * already-connected target — an in-page SPA route change or DOM-root * re-render that removed `#slicc-electron-overlay-root` while the * `__SLICC_ELECTRON_OVERLAY__` marker persists. Gated on the eviction probe * so it is idempotent and never loops while the host element is still * attached, and skipped while the CSP-bypass reload / Fetch-proxy escalation * owns injection (`pendingReload`). Re-uses the target's existing role * bootstrap, so no leader/follower re-election occurs. */ private reinjectIfEvicted; /** * Handle the initial CDP `ws.on('open', ...)` event for a target: enable * Runtime/Page, set CSP bypass, detect theme, inject the overlay, and (on a * first connect) probe whether the overlay iframe actually loaded — falling * back to a CSP-bypass reload by setting `state.pendingReload` and * `state.pendingCspEscalation` for the message handler to continue from. * * Mutating flow flags (`pendingReload`, `pendingCspEscalation`, * `fetchProxyActive`) live on the shared `state` object so this helper * preserves the original closure-driven control flow exactly. */ private handleSocketOpen; /** * Handle `Page.loadEventFired` after a CSP-bypass reload: re-inject the * themed overlay, then (if this load came from the simple-reload path) probe * the iframe again and, if still blocked, escalate to the Fetch HTTP proxy * which strips CSP from the document response. The proxy reload also sets * `pendingReload` again so the next `loadEventFired` re-injects on top of * the stripped response. */ private handlePageLoadAfterReload; /** * Handle a single `Fetch.requestPaused` event under the active Fetch proxy: * pass non-HTML requests straight through with `Fetch.continueRequest`, and * proxy HTML document requests through Node http/https so the response can * be returned via `Fetch.fulfillRequest` with CSP and hop-by-hop headers * stripped. `Fetch.fulfillRequest` is intentionally fire-and-forget — there * is no CDP reply for fulfill, and the response body is the document body. */ private handleFetchRequestPaused; /** * Inspect a `Network.*` CDP event for the egress-block signal: track OUR * overlay iframe's top-level Document request (matched by the per-process * bridge token in its URL, so the app's own frames are ignored) and, when the * app denies it at the network layer ({@link isOverlayEgressBlockError}), set * `state.egressBlocked` and record the target. `Page.setBypassCSP` / the Fetch * proxy operate above the layer that denies these, so escalation cannot help — * the probe/escalation paths read `state.egressBlocked` to bail out instead of * reload-churning and instead of falsely recording the target as loaded. */ private handleNetworkEventForEgressBlock; /** * Show the status-only overlay (launcher + message, no iframe) on a target * that blocks the embedded panel. Injects the status bootstrap now AND as a * `Page.addScriptToEvaluateOnNewDocument` hook so it survives app reloads — * the status hook is added after the role hook, so re-running both leaves the * idempotent launcher in the status-only state (the launcher is collapsed, so * there is no visible iframe flash). */ private injectStatusOverlay; private connectToTarget; }