@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.
- package/package.json +4 -2
- package/runtime/app/app.ts +15 -0
- package/runtime/app/main-page.ts +11 -2
- package/runtime/app/shell/custom-webview.ios.ts +18 -8
- package/runtime/app/shell/handlers-background.ts +1 -1
- package/runtime/app/shell/web-quirks.ts +63 -0
- package/runtime-desktop/autotest/index.html +77 -0
- package/runtime-desktop/bridge-shim/build.sh +9 -0
- package/runtime-desktop/bridge-shim/content-entry.ts +87 -0
- package/runtime-desktop/bridge-shim/shim.ts +317 -0
- package/runtime-desktop/src-tauri/Cargo.lock +5461 -0
- package/runtime-desktop/src-tauri/Cargo.toml +40 -0
- package/runtime-desktop/src-tauri/build.rs +3 -0
- package/runtime-desktop/src-tauri/capabilities/default.json +6 -0
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +10 -0
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +138 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +664 -0
- package/runtime-desktop/src-tauri/src/main.rs +879 -0
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +202 -0
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +139 -0
- package/runtime-desktop/src-tauri/src/popup_mac.rs +519 -0
- package/runtime-desktop/src-tauri/src/push_mac.rs +193 -0
- package/runtime-desktop/src-tauri/src/server.rs +156 -0
- package/runtime-desktop/src-tauri/src/sidecar.rs +398 -0
- package/runtime-desktop/src-tauri/tauri.conf.json +13 -0
- package/scripts/stage-assets.mjs +4 -2
- package/src/cli.ts +664 -37
- package/src/config.ts +76 -1
- package/src/desktop.ts +293 -0
- package/src/handlers.ts +71 -0
- 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.
|
|
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"
|
package/runtime/app/app.ts
CHANGED
|
@@ -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.
|
package/runtime/app/main-page.ts
CHANGED
|
@@ -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
|
-
|
|
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://
|
|
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,
|
|
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) +
|
|
189
|
-
// (
|
|
190
|
-
// suppression, all injected before the page's own scripts run. Globals first so the page
|
|
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
|
|
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,
|
|
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://
|
|
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
|
+
})();
|