@livx.cc/appwrap 0.42.4 → 0.46.1

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 (32) hide show
  1. package/package.json +4 -2
  2. package/runtime/app/app.ts +15 -0
  3. package/runtime/app/main-page.ts +11 -2
  4. package/runtime/app/shell/custom-webview.ios.ts +18 -8
  5. package/runtime/app/shell/handlers-background.ts +1 -1
  6. package/runtime/app/shell/web-quirks.ts +63 -0
  7. package/runtime-desktop/autotest/index.html +77 -0
  8. package/runtime-desktop/bridge-shim/build.sh +9 -0
  9. package/runtime-desktop/bridge-shim/content-entry.ts +87 -0
  10. package/runtime-desktop/bridge-shim/shim.ts +317 -0
  11. package/runtime-desktop/src-tauri/Cargo.lock +5461 -0
  12. package/runtime-desktop/src-tauri/Cargo.toml +40 -0
  13. package/runtime-desktop/src-tauri/build.rs +3 -0
  14. package/runtime-desktop/src-tauri/capabilities/default.json +6 -0
  15. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  16. package/runtime-desktop/src-tauri/shell_config.json +10 -0
  17. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +138 -0
  18. package/runtime-desktop/src-tauri/src/bridge_mac.rs +664 -0
  19. package/runtime-desktop/src-tauri/src/main.rs +879 -0
  20. package/runtime-desktop/src-tauri/src/notifications_mac.rs +202 -0
  21. package/runtime-desktop/src-tauri/src/oauth_mac.rs +139 -0
  22. package/runtime-desktop/src-tauri/src/popup_mac.rs +519 -0
  23. package/runtime-desktop/src-tauri/src/push_mac.rs +193 -0
  24. package/runtime-desktop/src-tauri/src/server.rs +156 -0
  25. package/runtime-desktop/src-tauri/src/sidecar.rs +398 -0
  26. package/runtime-desktop/src-tauri/tauri.conf.json +13 -0
  27. package/scripts/stage-assets.mjs +4 -2
  28. package/src/cli.ts +664 -37
  29. package/src/config.ts +76 -1
  30. package/src/desktop.ts +293 -0
  31. package/src/handlers.ts +71 -0
  32. package/src/icon.ts +207 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.42.4",
3
+ "version": "0.46.1",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -30,17 +30,19 @@
30
30
  "exports": {
31
31
  ".": "./src/config.ts",
32
32
  "./config": "./src/config.ts",
33
+ "./handlers": "./src/handlers.ts",
33
34
  "./cli": "./src/cli.ts"
34
35
  },
35
36
  "files": [
36
37
  "src",
37
38
  "runtime",
39
+ "runtime-desktop",
38
40
  "templates",
39
41
  "scripts/stage-assets.mjs"
40
42
  ],
41
43
  "scripts": {
42
44
  "prepack": "node scripts/stage-assets.mjs",
43
- "postpack": "rm -rf runtime templates"
45
+ "postpack": "rm -rf runtime runtime-desktop templates"
44
46
  },
45
47
  "publishConfig": {
46
48
  "access": "public"
@@ -48,6 +48,21 @@ if (isIOS) {
48
48
  onDeepLink(url.absoluteString);
49
49
  return true;
50
50
  }
51
+ // Universal links (https://<associated-domain>/...) arrive as a browsing user
52
+ // activity, NOT via openURL. Without this, tapping an https app link opens Safari
53
+ // instead of the app. Requires the associated-domains entitlement (iosEntitlements).
54
+ applicationContinueUserActivityRestorationHandler(
55
+ _app: UIApplication,
56
+ userActivity: NSUserActivity,
57
+ _restorationHandler: (restorableObjects: NSArray<any>) => void
58
+ ): boolean {
59
+ if (userActivity.activityType === NSUserActivityTypeBrowsingWeb && userActivity.webpageURL) {
60
+ console.log('AppWrap: universalLink', userActivity.webpageURL.absoluteString);
61
+ onDeepLink(userActivity.webpageURL.absoluteString);
62
+ return true;
63
+ }
64
+ return false;
65
+ }
51
66
  // Drives `kit.screen.orientation.lock` — UIKit reads this on every rotation.
52
67
  applicationSupportedInterfaceOrientationsForWindow(_app: UIApplication, _window: UIWindow): UIInterfaceOrientationMask {
53
68
  // iosOrientationMask() returns the raw bitmask as a number; the protocol types it as the enum.
@@ -116,7 +116,16 @@ export function onPageLoaded(args: EventData): void {
116
116
 
117
117
  const webView = page.getViewById<CustomWebView>('webview');
118
118
  bridge.attach(webView);
119
- if (isIOS) armNativeSurfaceRecovery(webView); // global freeze-recovery on any native modal dismiss
119
+ // NOTE: the GLOBAL UIWindowDidBecomeHidden freeze-recovery observer is intentionally NOT armed.
120
+ // It over-fired: the iOS edit menu / text-selection callout / AutoFill bar present in their own
121
+ // floating UIWindows, and their normal appear/disappear churn hides a transient window → the observer
122
+ // ran neutralizeStrayWindows() and DETACHED the just-shown edit-menu window (a plain UIWindow, so no
123
+ // class filter can distinguish it from a real orphan) → Copy/Paste + AutoFill silently dead on EVERY
124
+ // text field (device-verified via the [native:recover] log). Freeze recovery still runs via the
125
+ // explicit per-surface dismiss callbacks (billing/oauth/share/PHPicker/SafariVC → recoverAfterNativeSurface),
126
+ // which are the proven baseline and only fire for actual native surfaces. Re-add a scoped observer only
127
+ // if a future surface orphans a window with no dismiss callback — gate it on a "surface presented" flag.
128
+ void armNativeSurfaceRecovery; // referenced to keep the helper (still callable if a scoped need arises)
120
129
  if (isAndroid) wireAndroidSafeArea(webView); // experimental edge-to-edge (no-op unless config on)
121
130
  startEventForwarding();
122
131
  loadBundle(webView);
@@ -171,7 +180,7 @@ function loadBundle(webView: CustomWebView): void {
171
180
  const readAccessURL = NSURL.fileURLWithPathIsDirectory(wwwPath, true);
172
181
  wk.loadFileURLAllowingReadAccessToURL(entryURL, readAccessURL);
173
182
  } else {
174
- const url = NSURL.URLWithString(`app://local/${SHELL_CONFIG.entry}`);
183
+ const url = NSURL.URLWithString(`app://localhost/${SHELL_CONFIG.entry}`);
175
184
  wk.loadRequest(NSURLRequest.requestWithURL(url));
176
185
  }
177
186
  };
@@ -1,7 +1,7 @@
1
1
  import { WebView, knownFolders, path as nsPath, File } from '@nativescript/core';
2
2
  import { SHELL_CONFIG } from './config';
3
3
  import { mimeFor } from './mime';
4
- import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, mediaCaptureGuardJs, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
4
+ import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, capabilityGuardJs, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
5
5
  import { envGlobalsJs } from './env';
6
6
  import { createUiDelegate } from './ios-ui-delegate';
7
7
  import { appwrapNativeLog } from './native-log';
@@ -157,7 +157,7 @@ export class CustomWebView extends WebView {
157
157
  for (let j = 0; j < windows.count; j++) {
158
158
  const w = windows.objectAtIndex(j);
159
159
  if (main && w !== main && w.windowLevel > 0 && !w.hidden) {
160
- if (dbg) appendWebLog(`[native:recover] stray win[${j}] lvl=${w.windowLevel} uie=${w.userInteractionEnabled} key=${w.keyWindow}`);
160
+ if (dbg) appendWebLog(`[native:recover] stray win[${j}] ${NSStringFromClass(w.class())} lvl=${w.windowLevel} uie=${w.userInteractionEnabled} key=${w.keyWindow}`);
161
161
  w.userInteractionEnabled = false;
162
162
  w.hidden = true;
163
163
  w.windowScene = null; // detach from the scene — strongest removal
@@ -179,21 +179,31 @@ export class CustomWebView extends WebView {
179
179
  config.allowsInlineMediaPlayback = true;
180
180
  config.allowsPictureInPictureMediaPlayback = true;
181
181
  config.mediaTypesRequiringUserActionForPlayback = 0; // WKAudiovisualMediaTypeNone
182
+ // Element Fullscreen API (iOS 15.4+): the JS Fullscreen API on arbitrary elements is gated behind
183
+ // this preference (native <video> fullscreen works without it). respondsToSelector-guarded so it's a
184
+ // no-op on older iOS (mirrors the setInspectable: probe below).
185
+ if (config.preferences.respondsToSelector('setElementFullscreenEnabled:')) {
186
+ (config.preferences as WKPreferences & { elementFullscreenEnabled: boolean }).elementFullscreenEnabled = true;
187
+ }
182
188
  config.websiteDataStore = WKWebsiteDataStore.defaultDataStore();
183
189
  // Apple's hard requirement for a service worker in a WKWebView: restrict navigation to the
184
190
  // declared app-bound domains (also stamped to Info.plist WKAppBoundDomains). Only set when
185
191
  // configured — otherwise the WebView stays unrestricted (default behavior).
186
192
  if (SHELL_CONFIG.appBoundDomains?.length) config.limitsNavigationsToAppBoundDomains = true;
187
193
 
188
- // Env hints + framework globals (backend origin) + a getUserMedia guard for UNDECLARED capabilities
189
- // (must reject in JS before WebKit's native capture path — see mediaCaptureGuardJs) + native-feel
190
- // suppression, all injected before the page's own scripts run. Globals first so the page can read
191
- // __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__.
194
+ // Env hints + framework globals (backend origin) + the TCC capability guard for UNDECLARED web APIs
195
+ // (reject camera/mic/geolocation in JS before WebKit's native path — see capabilityGuardJs) +
196
+ // native-feel suppression, all injected before the page's own scripts run. Globals first so the page
197
+ // can read __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__.
192
198
  const hasPlistKey = (k: string) => !!NSBundle.mainBundle.objectForInfoDictionaryKey(k);
193
- const mediaGuard = mediaCaptureGuardJs(hasPlistKey('NSCameraUsageDescription'), hasPlistKey('NSMicrophoneUsageDescription'));
199
+ const capabilityGuard = capabilityGuardJs({
200
+ camera: hasPlistKey('NSCameraUsageDescription'),
201
+ microphone: hasPlistKey('NSMicrophoneUsageDescription'),
202
+ geolocation: hasPlistKey('NSLocationWhenInUseUsageDescription'),
203
+ });
194
204
  const swGuard = serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker);
195
205
  const extNavGuard = externalNavGuardJs(SHELL_CONFIG.openNewWindowsInBrowser);
196
- for (const src of [envGlobalsJs(), APPWRAP_GLOBALS_JS, mediaGuard, swGuard, extNavGuard, NATIVE_FEEL_JS]) {
206
+ for (const src of [envGlobalsJs(), APPWRAP_GLOBALS_JS, capabilityGuard, swGuard, extNavGuard, NATIVE_FEEL_JS]) {
197
207
  const script = WKUserScript.alloc().initWithSourceInjectionTimeForMainFrameOnly(
198
208
  src,
199
209
  WKUserScriptInjectionTime.AtDocumentStart,
@@ -102,7 +102,7 @@ function loadAppInto(webView: CustomWebView, id: string, attempt = 0): void {
102
102
  if (SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl) {
103
103
  wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(SHELL_CONFIG.serverUrl)));
104
104
  } else {
105
- wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(`app://local/${SHELL_CONFIG.entry}`)));
105
+ wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(`app://localhost/${SHELL_CONFIG.entry}`)));
106
106
  }
107
107
  } else {
108
108
  if (retry(!webView.android)) return;
@@ -71,6 +71,65 @@ export function mediaCaptureGuardJs(camera: boolean, microphone: boolean): strin
71
71
  })();`;
72
72
  }
73
73
 
74
+ /**
75
+ * Reject `navigator.geolocation` in JS at document-start when this build did NOT declare location
76
+ * (no NSLocationWhenInUseUsageDescription). Unlike camera/mic — where an undeclared request is a HARD
77
+ * TCC process-kill (see mediaCaptureGuardJs) — a WKWebView geolocation request with no usage string
78
+ * does NOT crash: WebKit's CoreLocation provider silently denies it. But the un-guarded failure mode is
79
+ * poor (the promise/callback can hang, and some iOS builds surface a confusing WebKit prompt for a
80
+ * permission the app can never grant). So when location is undeclared we fail FAST and deterministically
81
+ * — invoke the error callback with PERMISSION_DENIED so the page's own fallback runs immediately. When
82
+ * location IS declared, we no-op: WebKit + CoreLocation own the real prompt and the API works natively.
83
+ *
84
+ * `declared` = presence of NSLocationWhenInUseUsageDescription (the `geo` module). Patches BOTH
85
+ * getCurrentPosition and watchPosition on the live `navigator.geolocation`. Idempotent via a window guard.
86
+ */
87
+ export function geolocationGuardJs(declared: boolean): string {
88
+ if (declared) return '';
89
+ return `(function(){
90
+ var geo = navigator.geolocation;
91
+ if (!geo || window.__appwrapGeoGuard) return;
92
+ window.__appwrapGeoGuard = true;
93
+ function deny(_pos, err){
94
+ if (typeof err === 'function') {
95
+ // GeolocationPositionError.PERMISSION_DENIED === 1.
96
+ setTimeout(function(){ try { err({ code: 1, message: 'location not available (capability not enabled for this app)', PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 }); } catch (e) {} }, 0);
97
+ }
98
+ }
99
+ geo.getCurrentPosition = function(_ok, err){ deny(_ok, err); };
100
+ geo.watchPosition = function(_ok, err){ deny(_ok, err); return 0; };
101
+ geo.clearWatch = function(){};
102
+ })();`;
103
+ }
104
+
105
+ /** A snapshot of which TCC-gated capabilities THIS build declared (Info.plist usage strings present),
106
+ * derived by the shell from `NSBundle.mainBundle.objectForInfoDictionaryKey`. Drives capabilityGuardJs. */
107
+ export interface DeclaredCaps {
108
+ /** NSCameraUsageDescription present. */
109
+ camera: boolean;
110
+ /** NSMicrophoneUsageDescription present. */
111
+ microphone: boolean;
112
+ /** NSLocationWhenInUseUsageDescription present (the `geo` module). */
113
+ geolocation: boolean;
114
+ }
115
+
116
+ /**
117
+ * The single crash-proofing / graceful-degradation guard for ALL JS-reachable, TCC-gated web APIs in a
118
+ * WKWebView, injected at document-start. Takes the build's declared-capability snapshot and, for every
119
+ * capability the build did NOT declare, prevents the app-killing (camera/mic) or hanging (geolocation)
120
+ * failure while letting DECLARED capabilities pass straight through to their native prompt. Composes the
121
+ * individual guards so every consumer's foreign webview (shell + feedox mini-app views) is covered from
122
+ * one call site. Other TCC-gated surfaces (getDisplayMedia, SpeechRecognition, WebMIDI/BT/NFC/USB) are
123
+ * simply UNIMPLEMENTED by WKWebView — they resolve to `undefined`, so feature-detection degrades cleanly
124
+ * and there is nothing to guard.
125
+ */
126
+ export function capabilityGuardJs(declared: DeclaredCaps): string {
127
+ return [
128
+ mediaCaptureGuardJs(declared.camera, declared.microphone),
129
+ geolocationGuardJs(declared.geolocation),
130
+ ].filter(Boolean).join('\n');
131
+ }
132
+
74
133
  /**
75
134
  * Neutralize `navigator.serviceWorker.register` inside the native shell at document-start, so a
76
135
  * consumer PWA doesn't have to hand-gate its own SW. Inside the shell a service worker is at best
@@ -195,6 +254,10 @@ const NATIVE_FEEL_CSS = [
195
254
  // touch-callout:none suppresses long-press menus, but on inputs/textareas/contenteditable that also
196
255
  // kills the iOS Copy/Paste callout — so exclude editable fields (same set as the user-select rule).
197
256
  ':where(:not(input,textarea,[contenteditable],[contenteditable] *)){-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}',
257
+ // Both -webkit-touch-callout and user-select INHERIT, so a :not(input) exclusion isn't enough — the
258
+ // field still inherits `none` from body/form and loses its paste callout + AutoFill selection. Must
259
+ // EXPLICITLY re-enable editable fields (not merely exempt them).
260
+ 'input,textarea,[contenteditable],[contenteditable] *{-webkit-touch-callout:default;-webkit-user-select:text;user-select:text}',
198
261
  ].join('');
199
262
 
200
263
  /** Accessibility-standard reduced-motion reset — collapse animation/transition durations to ~0 and
@@ -0,0 +1,77 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <title>appwrap desktop autotest</title>
4
+ <body style="font-family:monospace;background:#111;color:#0f0"><pre id="log">autotest</pre>
5
+ <canvas id="cv" width="800" height="600"></canvas>
6
+ <script>
7
+ const logEl = document.getElementById('log');
8
+ const say = (m) => { logEl.textContent += '\n' + m; };
9
+ // Phase markers travel over the real bridge so they interleave with [bridge] eprintln
10
+ // lines next to the watchdog's memory samples. UNSUPPORTED responses are irrelevant.
11
+ const mark = (name) => window.appwrapNative?.postMessage(JSON.stringify({ v: 1, id: 'ph-' + name, kind: 'request', method: 'phase.' + name, params: null }));
12
+ window.__appwrapDeliver = () => {}; // swallow responses
13
+
14
+ const cleanups = [];
15
+ const phases = [
16
+ ['idle', () => {}],
17
+ ['motion-listeners', () => {
18
+ const h1 = () => {}; const h2 = () => {};
19
+ window.addEventListener('devicemotion', h1);
20
+ window.addEventListener('deviceorientation', h2);
21
+ cleanups.push(() => { window.removeEventListener('devicemotion', h1); window.removeEventListener('deviceorientation', h2); });
22
+ }],
23
+ ['raf-canvas', () => {
24
+ const ctx = document.getElementById('cv').getContext('2d');
25
+ let run = true, t = 0;
26
+ const loop = () => { if (!run) return; t++; ctx.fillStyle = `hsl(${t % 360},80%,50%)`; ctx.fillRect(0, 0, 800, 600); ctx.getImageData(0, 0, 100, 100); requestAnimationFrame(loop); };
27
+ loop();
28
+ cleanups.push(() => { run = false; });
29
+ }],
30
+ ['speech-tts', () => {
31
+ let run = true;
32
+ const speak = () => { if (!run) return; const u = new SpeechSynthesisUtterance('test'); u.volume = 0; u.onend = speak; speechSynthesis.speak(u); };
33
+ speak();
34
+ cleanups.push(() => { run = false; speechSynthesis.cancel(); });
35
+ }],
36
+ ['geo-watch', () => {
37
+ if (!navigator.geolocation) return say('no geolocation');
38
+ const id = navigator.geolocation.watchPosition(() => {}, () => {}, { enableHighAccuracy: true });
39
+ cleanups.push(() => navigator.geolocation.clearWatch(id));
40
+ }],
41
+ ['bridge-storage-spam', () => {
42
+ let run = true, i = 0;
43
+ const big = 'x'.repeat(100_000);
44
+ const tick = () => {
45
+ if (!run) return;
46
+ for (let j = 0; j < 20; j++) window.appwrapNative.postMessage(JSON.stringify({ v: 1, id: 's' + (++i), kind: 'request', method: 'storage.set', params: { key: 'k' + (i % 50), value: big } }));
47
+ setTimeout(tick, 100);
48
+ };
49
+ tick();
50
+ cleanups.push(() => { run = false; });
51
+ }],
52
+ ['toast-spam', () => {
53
+ let run = true, i = 0;
54
+ const tick = () => { if (!run || i > 40) return; i++; window.appwrapNative.postMessage(JSON.stringify({ v: 1, id: 't' + i, kind: 'request', method: 'toast.show', params: { message: 'spam ' + i } })); setTimeout(tick, 500); };
55
+ tick();
56
+ cleanups.push(() => { run = false; });
57
+ }],
58
+ ['getusermedia', () => {
59
+ navigator.mediaDevices?.getUserMedia({ video: true, audio: true })
60
+ .then((s) => { say('gum ok'); cleanups.push(() => s.getTracks().forEach((t) => t.stop())); })
61
+ .catch((e) => say('gum err ' + e.name));
62
+ }],
63
+ ['idle-end', () => {}],
64
+ ];
65
+
66
+ let idx = 0;
67
+ const next = () => {
68
+ while (cleanups.length) { try { cleanups.pop()(); } catch {} }
69
+ if (idx >= phases.length) { mark('DONE'); say('DONE'); return; }
70
+ const [name, fn] = phases[idx++];
71
+ mark(name); say('phase: ' + name);
72
+ try { fn(); } catch (e) { say('threw: ' + e.message); }
73
+ setTimeout(next, 30_000);
74
+ };
75
+ next();
76
+ </script>
77
+ </body>
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ # Build the injectable content bundle. Must run with the bod-browser repo as cwd so its
3
+ # tsconfig `paths` (shared/*) resolve — the bundle reuses extension/src/content/* unmodified.
4
+ set -e
5
+ HERE="$(cd "$(dirname "$0")" && pwd)"
6
+ BOD_BROWSER="${BOD_BROWSER:-$HERE/../../../bod-browser}"
7
+ cd "$BOD_BROWSER"
8
+ bun build "$HERE/content-entry.ts" --outfile "$HERE/dist/content.js"
9
+ echo "built $HERE/dist/content.js"
@@ -0,0 +1,87 @@
1
+ // SPIKE: browser-bridge content layer for the appwrap desktop (Tauri/WKWebView) shell.
2
+ // Reuses bod-browser's extension/src/content/* modules UNMODIFIED; only the transport differs:
3
+ // instead of chrome.runtime messaging, each page dials a local WS to the shim (bridge-shim/shim.ts)
4
+ // and answers ContentAction messages. Injected per-window via Tauri initialization_script.
5
+ //
6
+ // Build (from the bod-browser repo root so its tsconfig `paths` resolve shared/*):
7
+ // cd ../bod-browser && bun build <this file> --outfile <appwrap>/runtime-desktop/bridge-shim/dist/content.js
8
+ import type { ContentAction } from 'shared/protocol';
9
+ import { advanceScroll } from '../../../bod-browser/extension/src/content/advance-scroll';
10
+ import {
11
+ clickElement, typeInElement, fillElement, doScroll, scrollElementIntoView, evalScript,
12
+ waitForElement, hoverElement, scrollElementBy, hoverNear, sendKey,
13
+ } from '../../../bod-browser/extension/src/content/actions';
14
+ import {
15
+ queryElements, getLinks, findButtons, clickText, queryByText, getElementValue, setElementAttribute,
16
+ } from '../../../bod-browser/extension/src/content/query';
17
+ import { getA11ySnapshot, queryA11y } from '../../../bod-browser/extension/src/content/a11y';
18
+ import { probePage } from '../../../bod-browser/extension/src/content/probe';
19
+ import { diagnosePage } from '../../../bod-browser/extension/src/content/diagnose';
20
+
21
+ declare global {
22
+ interface Window { __BRIDGE_LABEL?: string; __BRIDGE_PORT?: number; __BRIDGE_LOADED?: boolean }
23
+ }
24
+
25
+ async function handleMessage(msg: ContentAction): Promise<unknown> {
26
+ switch (msg.action) {
27
+ case 'getContent':
28
+ return { url: location.href, title: document.title, html: document.documentElement.outerHTML, text: document.body?.innerText ?? '' };
29
+ case 'getTextContent':
30
+ return { url: location.href, title: document.title, text: document.body?.innerText?.slice(0, 20000) || '' };
31
+ case 'query': return queryElements(msg.selector, msg.multiple);
32
+ case 'click': return clickElement(msg.selector);
33
+ case 'type': return typeInElement(msg.selector, msg.value, msg.mode, msg.clear);
34
+ case 'fill': return fillElement(msg.selector, msg.value, msg.timeout, msg.mode, msg.clear);
35
+ case 'getValue': return getElementValue(msg.selector);
36
+ case 'setAttribute': return setElementAttribute(msg.selector, msg.attr, msg.value);
37
+ case 'scroll': return doScroll(msg.x || 0, msg.y || 0, msg.behavior);
38
+ case 'scrollIntoView': return scrollElementIntoView(msg.selector, msg.behavior);
39
+ case 'eval': return evalScript(msg.script);
40
+ case 'waitFor': return waitForElement(msg.selector, msg.timeout || 5000);
41
+ case 'hover': return hoverElement(msg.selector);
42
+ case 'scrollEl': return scrollElementBy(msg.selector, msg.deltaX || 0, msg.deltaY || 0, msg.behavior);
43
+ case 'advanceScroll':
44
+ return advanceScroll({ deltaY: msg.deltaY, itemSelector: msg.itemSelector, loadMoreSelector: msg.loadMoreSelector, behavior: msg.behavior });
45
+ case 'getA11ySnapshot': return getA11ySnapshot(msg.root || null, msg.maxDepth || 10, msg.mode || 'full');
46
+ case 'probe': return probePage(msg.goal || null);
47
+ case 'diagnose': return diagnosePage();
48
+ case 'queryA11y': return queryA11y(msg.role, msg.name, msg.multiple);
49
+ case 'getLinks': return getLinks(msg.pattern || null, msg.scope || null);
50
+ case 'clickText': return clickText(msg.text, msg.scope || null);
51
+ case 'queryByText': return queryByText(msg.text, msg.scope || null);
52
+ case 'findButtons': return findButtons(msg.scope || null);
53
+ case 'sendKey': return sendKey(msg.selector || null, msg.key, msg.modifiers);
54
+ case '_ping': return { pong: true };
55
+ default:
56
+ throw new Error(`Unknown action: ${(msg as ContentAction).action}`);
57
+ }
58
+ }
59
+
60
+ (() => {
61
+ // initialization_script can fire more than once per document lifecycle; guard.
62
+ if (window.__BRIDGE_LOADED) return;
63
+ window.__BRIDGE_LOADED = true;
64
+ const label = window.__BRIDGE_LABEL || 'unknown';
65
+
66
+ // Transport: WebKit blocks ws://127.0.0.1 (and plain-http beacons) from https pages as mixed
67
+ // content — no loopback exemption like Chrome. So results go out via the native
68
+ // WKScriptMessageHandler the shell installs (webkit.messageHandlers.bridgeShim), and commands
69
+ // come in via window.__bridgeDispatch(...) evaluated by the shell (evalJs op).
70
+ type WebkitHandlers = { webkit?: { messageHandlers?: { bridgeShim?: { postMessage: (s: string) => void } } } };
71
+ const post = (o: Record<string, unknown>) => {
72
+ try { (window as WebkitHandlers).webkit?.messageHandlers?.bridgeShim?.postMessage(JSON.stringify({ label, ...o })); } catch { /* ignore */ }
73
+ };
74
+
75
+ (window as unknown as { __bridgeDispatch: (m: ContentAction & { id: string }) => void }).__bridgeDispatch = (m) => {
76
+ Promise.resolve()
77
+ .then(() => handleMessage(m))
78
+ .then(result => post({ id: m.id, result }))
79
+ .catch((e: Error) => post({ id: m.id, error: e.message }));
80
+ };
81
+
82
+ const register = () => post({ type: 'register', url: location.href, title: document.title });
83
+ register();
84
+ addEventListener('DOMContentLoaded', register);
85
+ addEventListener('load', register);
86
+ console.log(`[BridgeDesktop:cs ${label}] loaded on ${location.href.slice(0, 80)}`);
87
+ })();