@livx.cc/appwrap 0.55.0 → 0.57.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.
- package/package.json +2 -3
- package/runtime/app/shell/capabilities.manifest.ts +3 -3
- package/runtime/tests/bridge-response-delivery.test.ts +10 -1
- package/runtime/tests/env-scheme.test.ts +6 -0
- package/scripts/stage-assets.mjs +1 -1
- package/src/cli.ts +40 -570
- package/src/config.ts +1 -1
- package/src/handlers.ts +1 -1
- package/src/packs.ts +1 -1
- package/templates/ci/fastlane/Fastfile +47 -6
- package/runtime-desktop/autotest/index.html +0 -77
- package/runtime-desktop/bridge-shim/build.sh +0 -9
- package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
- package/runtime-desktop/bridge-shim/shim.ts +0 -132
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
- package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
- package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
- package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
- package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
- package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
- package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
- package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
- package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
- package/runtime-desktop/src-tauri/Cargo.toml +0 -45
- package/runtime-desktop/src-tauri/build.rs +0 -3
- package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +0 -10
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
- package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
- package/runtime-desktop/src-tauri/src/main.rs +0 -1228
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
- package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
- package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
- package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
- package/runtime-desktop/src-tauri/src/server.rs +0 -156
- package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
- package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
- package/src/desktop.ts +0 -300
- package/src/plugin/host.ts +0 -242
package/src/config.ts
CHANGED
|
@@ -312,7 +312,7 @@ export interface AppwrapConfig {
|
|
|
312
312
|
* local directory OR an npm package name; a pack contributes `ModuleManifest[]` (+ handler files,
|
|
313
313
|
* native source, an optional kit client). Packs apply in order, LAST-WINS by module name, so a pack
|
|
314
314
|
* can add a NEW capability or wholesale-shadow a built-in one (e.g. swap the billing implementation).
|
|
315
|
-
* This is the single public extension seam — the same mechanism
|
|
315
|
+
* This is the single public extension seam — the same mechanism a host, consumer apps that vendor
|
|
316
316
|
* their own module, and community plugins all use. Absent → built-ins only (unchanged behavior). */
|
|
317
317
|
modulePacks?: string[];
|
|
318
318
|
/** Permitted headless background-task identifiers (for the `backgroundTask` module). iOS REQUIRES
|
package/src/handlers.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `defineHandlers` — author custom native handlers for the appwrap DESKTOP shell, run as a Bun sidecar.
|
|
3
3
|
*
|
|
4
|
-
* The desktop shell
|
|
4
|
+
* The desktop shell spawns `bun <yourFile>` when `desktop.handlers` is set, and routes
|
|
5
5
|
* any method your map advertises to this process over NDJSON on stdin/stdout (appwrap sidecar protocol v1).
|
|
6
6
|
* The PWA calls them through the kit with a raw invoke: `kit.invoke('myapp.foo', { … })`.
|
|
7
7
|
*
|
package/src/packs.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - a duplicate name WITHIN one pack → idempotent (later entry wins silently — a pack re-exporting its
|
|
15
15
|
* own module is not an error).
|
|
16
16
|
*
|
|
17
|
-
* This one mechanism serves three audiences identically:
|
|
17
|
+
* This one mechanism serves three audiences identically: a host layers its private packs, a consumer
|
|
18
18
|
* app vendors its own copy of a module, and the community ships plugins — none of them patch CE.
|
|
19
19
|
*
|
|
20
20
|
* PURE of CLI I/O: filesystem/npm resolution and pack import are injected ports (`resolvePack`/
|
|
@@ -6,6 +6,21 @@ default_platform(:ios)
|
|
|
6
6
|
APP_ID = '__APP_ID__'.freeze
|
|
7
7
|
TEAM_ID = '__TEAM_ID__'.freeze
|
|
8
8
|
|
|
9
|
+
# iOS app-extension targets discovered from the generated wrapper. Each dir under
|
|
10
|
+
# App_Resources/iOS/extensions/ carrying an extension.json is a SEPARATE Xcode target (name = dir
|
|
11
|
+
# name, e.g. AppwrapWidget / NotificationService). Its bundle id MUST be a child `<APP_ID>.<name>`
|
|
12
|
+
# (Apple rejects an extension whose id equals the app's or has >1 extra period), and it needs its
|
|
13
|
+
# OWN App Store profile. NS already stamps the correct child PRODUCT_BUNDLE_IDENTIFIER at prepare —
|
|
14
|
+
# the per-target signing below must NOT clobber it (a single unscoped update_code_signing_settings
|
|
15
|
+
# rewrites EVERY target's bundle id to APP_ID → collision at upload).
|
|
16
|
+
def appwrap_ios_extensions
|
|
17
|
+
ext_root = File.expand_path('../App_Resources/iOS/extensions', __dir__)
|
|
18
|
+
return [] unless Dir.exist?(ext_root)
|
|
19
|
+
Dir.children(ext_root)
|
|
20
|
+
.select { |name| File.file?(File.join(ext_root, name, 'extension.json')) }
|
|
21
|
+
.sort
|
|
22
|
+
end
|
|
23
|
+
|
|
9
24
|
# Run `ns prepare` (which shells out to `pod install`) with a SANITIZED gem env.
|
|
10
25
|
#
|
|
11
26
|
# Why: fastlane runs under its own Ruby and exports GEM_HOME/GEM_PATH into every child process. On a
|
|
@@ -50,8 +65,12 @@ def appwrap_archive_and_sign_ios
|
|
|
50
65
|
is_key_content_base64: true
|
|
51
66
|
)
|
|
52
67
|
|
|
53
|
-
#
|
|
54
|
-
|
|
68
|
+
# The app AND each extension sign with their OWN App Store profile — pull them all here.
|
|
69
|
+
# readonly: certs/profiles must be seeded once via `fastlane match appstore` (see workflow header);
|
|
70
|
+
# regenerate them (incl. per-extension) via the `regen_profiles` lane if capabilities changed.
|
|
71
|
+
extensions = appwrap_ios_extensions
|
|
72
|
+
ext_ids = extensions.map { |name| "#{APP_ID}.#{name}" }
|
|
73
|
+
match(type: 'appstore', readonly: true, app_identifier: [APP_ID, *ext_ids],
|
|
55
74
|
git_url: ENV['MATCH_GIT_URL'], api_key: api_key)
|
|
56
75
|
|
|
57
76
|
# NativeScript prepares the Xcode project inside the wrapper dir (native/ = parent of fastlane/).
|
|
@@ -59,22 +78,44 @@ def appwrap_archive_and_sign_ios
|
|
|
59
78
|
appwrap_prepare_ios
|
|
60
79
|
|
|
61
80
|
# NS defaults the project to automatic signing → archive can't find a profile. Pin manual signing
|
|
62
|
-
# to the match-provided App Store profile (name exported by match into ENV).
|
|
63
|
-
|
|
81
|
+
# PER TARGET to the match-provided App Store profile (name exported by match into ENV). `targets:`
|
|
82
|
+
# is REQUIRED so each call scopes to ONE target — an unscoped call rewrites EVERY target's
|
|
83
|
+
# bundle_identifier to APP_ID, clobbering the extensions' child ids → CFBundleIdentifier collision.
|
|
84
|
+
provisioning = {}
|
|
85
|
+
resolve_profile = ->(bundle_id) { ENV["sigh_#{bundle_id}_appstore_profile-name"] || "match AppStore #{bundle_id}" }
|
|
86
|
+
|
|
87
|
+
app_profile = resolve_profile.call(APP_ID)
|
|
88
|
+
provisioning[APP_ID] = app_profile
|
|
64
89
|
update_code_signing_settings(
|
|
65
90
|
use_automatic_signing: false,
|
|
66
91
|
path: 'platforms/ios/native.xcodeproj',
|
|
92
|
+
targets: ['native'], # main app target only — leave NS's child ids on the extensions untouched
|
|
67
93
|
team_id: TEAM_ID,
|
|
68
94
|
bundle_identifier: APP_ID,
|
|
69
95
|
code_sign_identity: 'Apple Distribution',
|
|
70
|
-
profile_name:
|
|
96
|
+
profile_name: app_profile
|
|
71
97
|
)
|
|
72
98
|
|
|
99
|
+
extensions.each do |name|
|
|
100
|
+
ext_id = "#{APP_ID}.#{name}"
|
|
101
|
+
ext_profile = resolve_profile.call(ext_id)
|
|
102
|
+
provisioning[ext_id] = ext_profile
|
|
103
|
+
update_code_signing_settings(
|
|
104
|
+
use_automatic_signing: false,
|
|
105
|
+
path: 'platforms/ios/native.xcodeproj',
|
|
106
|
+
targets: [name],
|
|
107
|
+
team_id: TEAM_ID,
|
|
108
|
+
bundle_identifier: ext_id,
|
|
109
|
+
code_sign_identity: 'Apple Distribution',
|
|
110
|
+
profile_name: ext_profile
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
73
114
|
build_app(
|
|
74
115
|
workspace: 'platforms/ios/native.xcworkspace',
|
|
75
116
|
scheme: 'native',
|
|
76
117
|
export_method: 'app-store',
|
|
77
|
-
export_options: { provisioningProfiles:
|
|
118
|
+
export_options: { provisioningProfiles: provisioning }
|
|
78
119
|
)
|
|
79
120
|
|
|
80
121
|
api_key
|
|
@@ -1,77 +0,0 @@
|
|
|
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>
|
|
@@ -1,9 +0,0 @@
|
|
|
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"
|
|
@@ -1,87 +0,0 @@
|
|
|
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
|
-
})();
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
// SPIKE: browser-bridge "extension instance" shim for the appwrap desktop (Tauri) shell.
|
|
3
|
-
// Speaks the UNMODIFIED bod-browser extension protocol to the bridge server (ws://localhost:9230),
|
|
4
|
-
// backed by Tauri windows instead of Chrome tabs:
|
|
5
|
-
// - WS client -> bridge server: hello { instanceId: 'appwrap-desktop' }, answers WsCommand.
|
|
6
|
-
// - WS server :9236 (/page) : each Tauri window's injected content bundle dials in and
|
|
7
|
-
// executes ContentAction messages (the DOM layer).
|
|
8
|
-
// - TCP :9237 (line-JSON) : the Rust shell connects as 'control'; the shim asks it for
|
|
9
|
-
// window ops (createWindow/closeWindow/navigate/screenshot).
|
|
10
|
-
// Run: bun runtime-desktop/bridge-shim/shim.ts
|
|
11
|
-
|
|
12
|
-
const SERVER_URL = process.env.BRIDGE_SERVER_URL || 'ws://localhost:9230';
|
|
13
|
-
const INSTANCE_ID = process.env.BRIDGE_INSTANCE_ID || 'appwrap-desktop';
|
|
14
|
-
const PAGE_PORT = Number(process.env.APPWRAP_BRIDGE_PAGE_PORT || 9236);
|
|
15
|
-
const RUST_PORT = Number(process.env.APPWRAP_BRIDGE_RUST_PORT || 9237);
|
|
16
|
-
|
|
17
|
-
const log = (m: string, ...a: unknown[]) => console.log(`[shim] ${m}`, ...a);
|
|
18
|
-
|
|
19
|
-
// The transport-agnostic browser-automation PRODUCT (tab registry + bod-browser routing +
|
|
20
|
-
// handleCommand) was lifted into the `browser-driver` package inside mcp-desktop-browser (Phase 1b).
|
|
21
|
-
// This file is now a thin TRANSPORT shell: WS client + TCP control channel + page WS, delegating all
|
|
22
|
-
// command handling and page-beacon parsing to the driver below. Consumed as a proper package
|
|
23
|
-
// (`mcp-desktop-browser/browser-driver`, a file: dep in the root package.json → resolves by name,
|
|
24
|
-
// not a fragile cross-repo relative path — the TS analogue of the crate-consumption decision).
|
|
25
|
-
import { createBrowserDriver } from 'mcp-desktop-browser/browser-driver';
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Rust control channel (TCP, newline-delimited JSON)
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> };
|
|
31
|
-
let rustSock: { write: (s: string) => void } | null = null;
|
|
32
|
-
const rustPending = new Map<string, Pending>();
|
|
33
|
-
let reqCounter = 0;
|
|
34
|
-
|
|
35
|
-
function rustRequest(op: string, params: Record<string, unknown> = {}, timeoutMs = 15000): Promise<unknown> {
|
|
36
|
-
if (!rustSock) return Promise.reject(new Error('Desktop shell not connected to shim (is the app running with APPWRAP_BRIDGE_JS?)'));
|
|
37
|
-
const id = `r${++reqCounter}`;
|
|
38
|
-
return new Promise((resolve, reject) => {
|
|
39
|
-
const timer = setTimeout(() => { rustPending.delete(id); reject(new Error(`rust op ${op} timed out`)); }, timeoutMs);
|
|
40
|
-
rustPending.set(id, { resolve, reject, timer });
|
|
41
|
-
rustSock!.write(JSON.stringify({ id, op, ...params }) + '\n');
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// The product layer — driven through our transport (rustRequest) + fed page beacons via onPageMsg.
|
|
46
|
-
const driver = createBrowserDriver({ rustRequest, log });
|
|
47
|
-
|
|
48
|
-
Bun.listen({
|
|
49
|
-
hostname: '127.0.0.1',
|
|
50
|
-
port: RUST_PORT,
|
|
51
|
-
socket: {
|
|
52
|
-
open(socket) {
|
|
53
|
-
log('rust control connected');
|
|
54
|
-
rustSock = { write: (s) => socket.write(s) };
|
|
55
|
-
(socket as unknown as { __buf: string }).__buf = '';
|
|
56
|
-
},
|
|
57
|
-
data(socket, data) {
|
|
58
|
-
const s = socket as unknown as { __buf: string };
|
|
59
|
-
s.__buf += data.toString();
|
|
60
|
-
let idx: number;
|
|
61
|
-
while ((idx = s.__buf.indexOf('\n')) >= 0) {
|
|
62
|
-
const line = s.__buf.slice(0, idx).trim();
|
|
63
|
-
s.__buf = s.__buf.slice(idx + 1);
|
|
64
|
-
if (!line) continue;
|
|
65
|
-
try {
|
|
66
|
-
const msg = JSON.parse(line);
|
|
67
|
-
if (msg.type === 'pageMsg') { driver.onPageMsg(String(msg.payload)); continue; }
|
|
68
|
-
const p = rustPending.get(msg.id);
|
|
69
|
-
if (p) {
|
|
70
|
-
rustPending.delete(msg.id);
|
|
71
|
-
clearTimeout(p.timer);
|
|
72
|
-
if (msg.error) p.reject(new Error(String(msg.error)));
|
|
73
|
-
else p.resolve(msg.result);
|
|
74
|
-
}
|
|
75
|
-
} catch (e) { log('bad rust line:', line.slice(0, 200), (e as Error).message); }
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
close() { log('rust control disconnected'); rustSock = null; },
|
|
79
|
-
error(_s, e) { log('rust control error:', e.message); },
|
|
80
|
-
},
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// Debug HTTP endpoint (curl a raw rust op / read page beacons)
|
|
84
|
-
Bun.serve({
|
|
85
|
-
hostname: '127.0.0.1',
|
|
86
|
-
port: PAGE_PORT,
|
|
87
|
-
async fetch(req) {
|
|
88
|
-
const path = new URL(req.url).pathname;
|
|
89
|
-
if (path === '/log' && req.method === 'POST') { log('page:', await req.text()); return new Response('ok'); }
|
|
90
|
-
if (path === '/rust' && req.method === 'POST') {
|
|
91
|
-
const body = await req.json() as Record<string, unknown>;
|
|
92
|
-
try { return Response.json({ result: await rustRequest(String(body.op), body) }); }
|
|
93
|
-
catch (e) { return Response.json({ error: (e as Error).message }); }
|
|
94
|
-
}
|
|
95
|
-
return new Response('bridge-shim debug endpoint', { status: 200 });
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
// Bridge server WS client (the extension background's ws.ts analogue)
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
let serverWs: WebSocket | null = null;
|
|
103
|
-
function connectServer() {
|
|
104
|
-
serverWs = new WebSocket(SERVER_URL);
|
|
105
|
-
serverWs.onopen = () => {
|
|
106
|
-
log(`connected to bridge server ${SERVER_URL} as ${INSTANCE_ID}`);
|
|
107
|
-
serverWs!.send(JSON.stringify({ type: 'hello', userAgent: `appwrap-desktop-shell (Tauri/WKWebView; ${process.platform})`, instanceId: INSTANCE_ID, buildHash: 'appwrap-desktop-spike' }));
|
|
108
|
-
serverWs!.send(JSON.stringify({ type: 'ping', t: Date.now() }));
|
|
109
|
-
};
|
|
110
|
-
serverWs.onmessage = async (ev) => {
|
|
111
|
-
let msg: { id: string; cmd: string; params?: Record<string, unknown>; type?: string } | undefined;
|
|
112
|
-
try {
|
|
113
|
-
const parsed = JSON.parse(String(ev.data));
|
|
114
|
-
if (parsed.type === 'pong' || parsed.type === 'reload' || parsed.type === 'recording_saved') return;
|
|
115
|
-
msg = parsed;
|
|
116
|
-
const result = await driver.handleCommand(msg!);
|
|
117
|
-
log(`cmd=${msg!.cmd} ok`);
|
|
118
|
-
serverWs!.send(JSON.stringify({ id: msg!.id, result }));
|
|
119
|
-
} catch (e) {
|
|
120
|
-
log(`cmd=${msg?.cmd} error: ${(e as Error).message}`);
|
|
121
|
-
try { serverWs!.send(JSON.stringify({ id: msg?.id, error: (e as Error).message })); } catch { /* ignore */ }
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
serverWs.onclose = () => { log('server disconnected, retrying in 3s'); setTimeout(connectServer, 3000); };
|
|
125
|
-
serverWs.onerror = () => { /* onclose follows */ };
|
|
126
|
-
}
|
|
127
|
-
connectServer();
|
|
128
|
-
setInterval(() => {
|
|
129
|
-
if (serverWs?.readyState === WebSocket.OPEN) serverWs.send(JSON.stringify({ type: 'ping', t: Date.now() }));
|
|
130
|
-
}, 15000);
|
|
131
|
-
|
|
132
|
-
log(`page WS :${PAGE_PORT} rust control :${RUST_PORT}`);
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html>
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>Browser</title>
|
|
7
|
-
<style>
|
|
8
|
-
:root {
|
|
9
|
-
--bar-h: 44px;
|
|
10
|
-
--bg: #f2f2f4; --fg: #1c1c1e; --border: #d0d0d5;
|
|
11
|
-
--field-bg: #ffffff; --field-border: #c4c4cc; --btn-hover: #e2e2e6;
|
|
12
|
-
}
|
|
13
|
-
@media (prefers-color-scheme: dark) {
|
|
14
|
-
:root {
|
|
15
|
-
--bg: #2a2a2e; --fg: #f2f2f4; --border: #444;
|
|
16
|
-
--field-bg: #1c1c1e; --field-border: #555; --btn-hover: #3a3a40;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
* { box-sizing: border-box; }
|
|
20
|
-
html, body { margin: 0; height: 100%; overflow: hidden;
|
|
21
|
-
font: 13px -apple-system, system-ui, sans-serif; color: var(--fg); }
|
|
22
|
-
/* The controlled WKWebView is a native subview covering everything below the bar; the body below
|
|
23
|
-
the bar is never seen — only this top strip renders. */
|
|
24
|
-
#bar {
|
|
25
|
-
display: flex; align-items: center; gap: 6px;
|
|
26
|
-
height: var(--bar-h); padding: 0 8px;
|
|
27
|
-
background: var(--bg); border-bottom: 1px solid var(--border);
|
|
28
|
-
-webkit-user-select: none; user-select: none;
|
|
29
|
-
}
|
|
30
|
-
button.nav {
|
|
31
|
-
width: 30px; height: 28px; flex: none;
|
|
32
|
-
border: none; border-radius: 6px; background: transparent; color: var(--fg);
|
|
33
|
-
font-size: 16px; line-height: 1; cursor: default;
|
|
34
|
-
}
|
|
35
|
-
button.nav:hover { background: var(--btn-hover); }
|
|
36
|
-
button.nav:active { background: var(--field-border); }
|
|
37
|
-
#addr {
|
|
38
|
-
flex: 1 1 auto; height: 28px; padding: 0 10px;
|
|
39
|
-
border: 1px solid var(--field-border); border-radius: 7px;
|
|
40
|
-
background: var(--field-bg); color: var(--fg); font-size: 13px; outline: none;
|
|
41
|
-
}
|
|
42
|
-
#addr:focus { border-color: #4c8bf5; }
|
|
43
|
-
#profile, #newprof {
|
|
44
|
-
flex: none; height: 28px; max-width: 160px;
|
|
45
|
-
border: 1px solid var(--field-border); border-radius: 7px;
|
|
46
|
-
background: var(--field-bg); color: var(--fg); font-size: 12px; padding: 0 4px;
|
|
47
|
-
}
|
|
48
|
-
#newprof:focus { border-color: #4c8bf5; outline: none; }
|
|
49
|
-
</style>
|
|
50
|
-
</head>
|
|
51
|
-
<body>
|
|
52
|
-
<div id="bar">
|
|
53
|
-
<button class="nav" id="back" title="Back">‹</button>
|
|
54
|
-
<button class="nav" id="fwd" title="Forward">›</button>
|
|
55
|
-
<button class="nav" id="reload" title="Reload">↻</button>
|
|
56
|
-
<input id="addr" type="text" spellcheck="false" autocomplete="off"
|
|
57
|
-
placeholder="Enter address (e.g. 127.0.0.1:3000)" />
|
|
58
|
-
<select id="profile" title="Profile"></select>
|
|
59
|
-
<input id="newprof" type="text" spellcheck="false" autocomplete="off"
|
|
60
|
-
placeholder="profile name — Enter" style="display:none" />
|
|
61
|
-
</div>
|
|
62
|
-
<script>
|
|
63
|
-
(function () {
|
|
64
|
-
// id + profile arrive via hash fragment (#id=…&profile=…) — a query (?…) breaks Tauri's App-asset
|
|
65
|
-
// resolution. Read hash first, fall back to search for robustness.
|
|
66
|
-
var hp = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
67
|
-
var sp = new URLSearchParams(location.search);
|
|
68
|
-
var id = hp.get('id') || sp.get('id') || '';
|
|
69
|
-
var initProfile = hp.get('profile') || sp.get('profile') || 'default';
|
|
70
|
-
function invoke(cmd, args) {
|
|
71
|
-
var core = window.__TAURI__ && window.__TAURI__.core;
|
|
72
|
-
if (!core) return Promise.reject(new Error('Tauri core unavailable'));
|
|
73
|
-
return core.invoke(cmd, args || {});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
var addr = document.getElementById('addr');
|
|
77
|
-
var profileSel = document.getElementById('profile');
|
|
78
|
-
|
|
79
|
-
function normalizeUrl(raw) {
|
|
80
|
-
var s = (raw || '').trim();
|
|
81
|
-
if (!s) return '';
|
|
82
|
-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) || /^about:/i.test(s)) return s;
|
|
83
|
-
return 'https://' + s;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
document.getElementById('back').addEventListener('click', function () { invoke('browser_tab_back', { id: id }); });
|
|
87
|
-
document.getElementById('fwd').addEventListener('click', function () { invoke('browser_tab_forward', { id: id }); });
|
|
88
|
-
document.getElementById('reload').addEventListener('click', function () { invoke('browser_tab_reload', { id: id }); });
|
|
89
|
-
|
|
90
|
-
addr.addEventListener('keydown', function (e) {
|
|
91
|
-
if (e.key !== 'Enter') return;
|
|
92
|
-
var url = normalizeUrl(addr.value);
|
|
93
|
-
if (url) invoke('browser_tab_navigate', { id: id, url: url });
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
var NEW_PROFILE = '__new__'; // sentinel option: reveal an inline input, then switch to the name
|
|
97
|
-
var current = initProfile; // the profile actually shown (to revert the <select> on cancel)
|
|
98
|
-
var newprof = document.getElementById('newprof');
|
|
99
|
-
|
|
100
|
-
function addProfileOption(name) {
|
|
101
|
-
if (!name) return;
|
|
102
|
-
for (var i = 0; i < profileSel.options.length; i++) {
|
|
103
|
-
if (profileSel.options[i].value === name) return; // already listed
|
|
104
|
-
}
|
|
105
|
-
var opt = document.createElement('option');
|
|
106
|
-
opt.value = name; opt.textContent = name;
|
|
107
|
-
profileSel.insertBefore(opt, profileSel.lastChild); // before the "+ New profile…" item
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function switchProfile(name) {
|
|
111
|
-
// Switching recreates the webview with a fresh, isolated store; the name is persisted on first
|
|
112
|
-
// use (uuid_for) so it reappears in the list next launch.
|
|
113
|
-
addProfileOption(name);
|
|
114
|
-
profileSel.value = name;
|
|
115
|
-
current = name;
|
|
116
|
-
invoke('browser_subwindow_switch_profile', { id: id, profileId: name });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Inline "+ New profile" flow — WKWebView has no window.prompt (no text-input panel delegate), so
|
|
120
|
-
// reveal a small input instead of prompting. Enter commits, Escape/empty-blur cancels.
|
|
121
|
-
function beginNewProfile() {
|
|
122
|
-
profileSel.style.display = 'none';
|
|
123
|
-
newprof.style.display = '';
|
|
124
|
-
newprof.value = '';
|
|
125
|
-
newprof.focus();
|
|
126
|
-
}
|
|
127
|
-
function endNewProfile(commit) {
|
|
128
|
-
if (newprof.style.display === 'none') return; // already closed (guard double-fire)
|
|
129
|
-
var name = (newprof.value || '').trim();
|
|
130
|
-
newprof.style.display = 'none';
|
|
131
|
-
profileSel.style.display = '';
|
|
132
|
-
profileSel.value = current; // reset the sentinel selection
|
|
133
|
-
if (commit && name && name !== current) switchProfile(name);
|
|
134
|
-
}
|
|
135
|
-
newprof.addEventListener('keydown', function (e) {
|
|
136
|
-
if (e.key === 'Enter') endNewProfile(true);
|
|
137
|
-
else if (e.key === 'Escape') endNewProfile(false);
|
|
138
|
-
});
|
|
139
|
-
newprof.addEventListener('blur', function () { endNewProfile(true); });
|
|
140
|
-
|
|
141
|
-
profileSel.addEventListener('change', function () {
|
|
142
|
-
var val = profileSel.value;
|
|
143
|
-
if (val === NEW_PROFILE) { beginNewProfile(); return; }
|
|
144
|
-
current = val;
|
|
145
|
-
invoke('browser_subwindow_switch_profile', { id: id, profileId: val });
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// Populate the profile dropdown, then append the "+ New profile…" sentinel.
|
|
149
|
-
invoke('browser_list_profiles').then(function (profiles) {
|
|
150
|
-
(profiles || ['default']).forEach(function (p) {
|
|
151
|
-
var opt = document.createElement('option');
|
|
152
|
-
opt.value = p; opt.textContent = p;
|
|
153
|
-
profileSel.appendChild(opt);
|
|
154
|
-
});
|
|
155
|
-
var add = document.createElement('option');
|
|
156
|
-
add.value = NEW_PROFILE; add.textContent = '+ New profile…';
|
|
157
|
-
profileSel.appendChild(add);
|
|
158
|
-
// Reflect the window's ACTUAL mounted profile (may be non-default on a restored window).
|
|
159
|
-
addProfileOption(initProfile);
|
|
160
|
-
profileSel.value = initProfile;
|
|
161
|
-
}).catch(function (e) { console.error('list profiles failed', e); });
|
|
162
|
-
|
|
163
|
-
// Keep the address bar in sync with the controlled webview (skip while the user is editing it).
|
|
164
|
-
var focused = false;
|
|
165
|
-
addr.addEventListener('focus', function () { focused = true; });
|
|
166
|
-
addr.addEventListener('blur', function () { focused = false; });
|
|
167
|
-
function poll() {
|
|
168
|
-
invoke('browser_tab_current_url', { id: id }).then(function (json) {
|
|
169
|
-
if (focused) return;
|
|
170
|
-
try {
|
|
171
|
-
var o = JSON.parse(json);
|
|
172
|
-
var href = o && o.href ? o.href : '';
|
|
173
|
-
if (href && href !== 'about:blank') addr.value = href;
|
|
174
|
-
} catch (_) {}
|
|
175
|
-
}).catch(function () {});
|
|
176
|
-
}
|
|
177
|
-
var pollTimer = setInterval(poll, 1000);
|
|
178
|
-
poll();
|
|
179
|
-
// Stop polling as the window unloads — a poll's current_url eval racing the native teardown blocks
|
|
180
|
-
// the closing window (the webview is gone, so the eval never resolves). Fixes the close-hang.
|
|
181
|
-
window.addEventListener('pagehide', function () { clearInterval(pollTimer); });
|
|
182
|
-
})();
|
|
183
|
-
</script>
|
|
184
|
-
</body>
|
|
185
|
-
</html>
|