@livx.cc/appwrap 0.43.0 → 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 +1 -1
- 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 +2 -0
- package/runtime-desktop/src-tauri/Cargo.toml +2 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +664 -0
- package/runtime-desktop/src-tauri/src/main.rs +198 -2
- package/runtime-desktop/src-tauri/src/popup_mac.rs +63 -10
- package/runtime-desktop/src-tauri/src/server.rs +156 -0
- package/src/cli.ts +70 -13
- package/src/config.ts +36 -0
- package/src/desktop.ts +48 -1
package/package.json
CHANGED
|
@@ -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
|
+
})();
|
|
@@ -0,0 +1,317 @@
|
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Tab registry: Tauri window label <-> numeric tabId (chrome.tabs.Tab-ish shape)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
interface TabEntry { tabId: number; label: string; url: string; title: string; registered: boolean; profileId?: string }
|
|
23
|
+
const tabs = new Map<string, TabEntry>(); // by label
|
|
24
|
+
let tabCounter = 0;
|
|
25
|
+
let activeLabel: string | null = null;
|
|
26
|
+
|
|
27
|
+
function tabShape(t: TabEntry, active = t.label === activeLabel) {
|
|
28
|
+
return { id: t.tabId, url: t.url, title: t.title, active, windowId: 1, status: t.registered ? 'complete' : 'loading', label: t.label, profileId: t.profileId };
|
|
29
|
+
}
|
|
30
|
+
function byTabId(tabId?: number): TabEntry {
|
|
31
|
+
if (tabId != null) {
|
|
32
|
+
const t = [...tabs.values()].find(t => t.tabId === Number(tabId));
|
|
33
|
+
if (!t) throw new Error(`No such tab: ${tabId}`);
|
|
34
|
+
return t;
|
|
35
|
+
}
|
|
36
|
+
const t = (activeLabel && tabs.get(activeLabel)) || [...tabs.values()].pop();
|
|
37
|
+
if (!t) throw new Error('No windows open (use newTab first)');
|
|
38
|
+
return t;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Rust control channel (TCP, newline-delimited JSON)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> };
|
|
45
|
+
let rustSock: { write: (s: string) => void } | null = null;
|
|
46
|
+
const rustPending = new Map<string, Pending>();
|
|
47
|
+
let reqCounter = 0;
|
|
48
|
+
|
|
49
|
+
function rustRequest(op: string, params: Record<string, unknown> = {}, timeoutMs = 15000): Promise<unknown> {
|
|
50
|
+
if (!rustSock) return Promise.reject(new Error('Desktop shell not connected to shim (is the app running with APPWRAP_BRIDGE_JS?)'));
|
|
51
|
+
const id = `r${++reqCounter}`;
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const timer = setTimeout(() => { rustPending.delete(id); reject(new Error(`rust op ${op} timed out`)); }, timeoutMs);
|
|
54
|
+
rustPending.set(id, { resolve, reject, timer });
|
|
55
|
+
rustSock!.write(JSON.stringify({ id, op, ...params }) + '\n');
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
Bun.listen({
|
|
60
|
+
hostname: '127.0.0.1',
|
|
61
|
+
port: RUST_PORT,
|
|
62
|
+
socket: {
|
|
63
|
+
open(socket) {
|
|
64
|
+
log('rust control connected');
|
|
65
|
+
rustSock = { write: (s) => socket.write(s) };
|
|
66
|
+
(socket as unknown as { __buf: string }).__buf = '';
|
|
67
|
+
},
|
|
68
|
+
data(socket, data) {
|
|
69
|
+
const s = socket as unknown as { __buf: string };
|
|
70
|
+
s.__buf += data.toString();
|
|
71
|
+
let idx: number;
|
|
72
|
+
while ((idx = s.__buf.indexOf('\n')) >= 0) {
|
|
73
|
+
const line = s.__buf.slice(0, idx).trim();
|
|
74
|
+
s.__buf = s.__buf.slice(idx + 1);
|
|
75
|
+
if (!line) continue;
|
|
76
|
+
try {
|
|
77
|
+
const msg = JSON.parse(line);
|
|
78
|
+
if (msg.type === 'pageMsg') { onPageMsg(String(msg.payload)); continue; }
|
|
79
|
+
const p = rustPending.get(msg.id);
|
|
80
|
+
if (p) {
|
|
81
|
+
rustPending.delete(msg.id);
|
|
82
|
+
clearTimeout(p.timer);
|
|
83
|
+
if (msg.error) p.reject(new Error(String(msg.error)));
|
|
84
|
+
else p.resolve(msg.result);
|
|
85
|
+
}
|
|
86
|
+
} catch (e) { log('bad rust line:', line.slice(0, 200), (e as Error).message); }
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
close() { log('rust control disconnected'); rustSock = null; },
|
|
90
|
+
error(_s, e) { log('rust control error:', e.message); },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Page messages (via the shell's WKScriptMessageHandler -> pageMsg lines) and
|
|
96
|
+
// command dispatch (via evalJs -> window.__bridgeDispatch)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
const pagePending = new Map<string, Pending>();
|
|
99
|
+
|
|
100
|
+
function onPageMsg(payload: string) {
|
|
101
|
+
let msg: Record<string, unknown>;
|
|
102
|
+
try { msg = JSON.parse(payload); } catch { log('bad pageMsg:', payload.slice(0, 200)); return; }
|
|
103
|
+
const label = String(msg.label || '');
|
|
104
|
+
if (msg.type === 'register') {
|
|
105
|
+
let t = tabs.get(label);
|
|
106
|
+
if (!t) { t = { tabId: ++tabCounter, label, url: '', title: '', registered: false }; tabs.set(label, t); }
|
|
107
|
+
t.registered = true;
|
|
108
|
+
t.url = String(msg.url || t.url);
|
|
109
|
+
t.title = String(msg.title || t.title);
|
|
110
|
+
log(`page registered: ${label} tabId=${t.tabId} ${t.url.slice(0, 80)}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const p = pagePending.get(String(msg.id));
|
|
114
|
+
if (p) {
|
|
115
|
+
pagePending.delete(String(msg.id));
|
|
116
|
+
clearTimeout(p.timer);
|
|
117
|
+
if (msg.error) p.reject(new Error(String(msg.error)));
|
|
118
|
+
else p.resolve(msg.result);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Debug HTTP endpoint (curl a raw rust op / read page beacons)
|
|
123
|
+
Bun.serve({
|
|
124
|
+
hostname: '127.0.0.1',
|
|
125
|
+
port: PAGE_PORT,
|
|
126
|
+
async fetch(req) {
|
|
127
|
+
const path = new URL(req.url).pathname;
|
|
128
|
+
if (path === '/log' && req.method === 'POST') { log('page:', await req.text()); return new Response('ok'); }
|
|
129
|
+
if (path === '/rust' && req.method === 'POST') {
|
|
130
|
+
const body = await req.json() as Record<string, unknown>;
|
|
131
|
+
try { return Response.json({ result: await rustRequest(String(body.op), body) }); }
|
|
132
|
+
catch (e) { return Response.json({ error: (e as Error).message }); }
|
|
133
|
+
}
|
|
134
|
+
return new Response('bridge-shim debug endpoint', { status: 200 });
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function pageExec(t: TabEntry, action: Record<string, unknown>, timeoutMs = 30000): Promise<unknown> {
|
|
139
|
+
const id = `p${++reqCounter}`;
|
|
140
|
+
const dispatch = `window.__bridgeDispatch && window.__bridgeDispatch(${JSON.stringify({ id, ...action })})`;
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const timer = setTimeout(() => { pagePending.delete(id); reject(new Error(`exec ${action.action} timed out on tab ${t.tabId} (page not bridged?)`)); }, timeoutMs);
|
|
143
|
+
pagePending.set(id, { resolve, reject, timer });
|
|
144
|
+
rustRequest('evalJs', { label: t.label, js: dispatch }).catch(e => {
|
|
145
|
+
if (pagePending.delete(id)) { clearTimeout(timer); reject(e as Error); }
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Wait until the tab's page socket is (re)connected — used after newTab/navigate. */
|
|
151
|
+
async function waitForPage(label: string, timeoutMs = 15000): Promise<TabEntry> {
|
|
152
|
+
const start = Date.now();
|
|
153
|
+
for (;;) {
|
|
154
|
+
const t = tabs.get(label);
|
|
155
|
+
if (t?.registered) return t;
|
|
156
|
+
if (Date.now() - start > timeoutMs) throw new Error(`page for ${label} did not connect within ${timeoutMs}ms`);
|
|
157
|
+
await new Promise(r => setTimeout(r, 100));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Command handling (the extension-background analogue)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
async function handleCommand(msg: { id: string; cmd: string; params?: Record<string, unknown> }): Promise<unknown> {
|
|
165
|
+
const params = (msg.params || {}) as Record<string, unknown> & { tabId?: number; url?: string };
|
|
166
|
+
switch (msg.cmd) {
|
|
167
|
+
case 'ping': return { pong: true };
|
|
168
|
+
case 'getTabs': return [...tabs.values()].map(t => tabShape(t));
|
|
169
|
+
case 'getActiveTab': return tabShape(byTabId(undefined));
|
|
170
|
+
case 'newTab': {
|
|
171
|
+
const url = params.url || 'https://example.com';
|
|
172
|
+
// Optional profileId → the shell creates a manual window bound to that profile's persistent,
|
|
173
|
+
// isolated WKWebsiteDataStore (see bridge_mac.rs). Absent → a plain tauri window.
|
|
174
|
+
const profileId = (params as { profileId?: string }).profileId;
|
|
175
|
+
// Unique across shim restarts — the app may still hold windows from a previous shim run.
|
|
176
|
+
const label = `bridge-${Date.now().toString(36)}-${++tabCounter}`;
|
|
177
|
+
const t: TabEntry = { tabId: tabCounter, label, url: String(url), title: '', registered: false, profileId };
|
|
178
|
+
tabs.set(label, t);
|
|
179
|
+
activeLabel = label;
|
|
180
|
+
await rustRequest('createWindow', { label, url, ...(profileId ? { profileId } : {}) });
|
|
181
|
+
await waitForPage(label).catch(e => log(`newTab: ${e.message}`)); // tab exists even if slow to load
|
|
182
|
+
return tabShape(t);
|
|
183
|
+
}
|
|
184
|
+
case 'closeTab': {
|
|
185
|
+
const t = byTabId(params.tabId);
|
|
186
|
+
await rustRequest('closeWindow', { label: t.label });
|
|
187
|
+
tabs.delete(t.label);
|
|
188
|
+
if (activeLabel === t.label) activeLabel = [...tabs.keys()].pop() ?? null;
|
|
189
|
+
return { success: true };
|
|
190
|
+
}
|
|
191
|
+
case 'navigate': {
|
|
192
|
+
const t = byTabId(params.tabId);
|
|
193
|
+
t.registered = false; // old document is going away
|
|
194
|
+
t.url = String(params.url);
|
|
195
|
+
activeLabel = t.label;
|
|
196
|
+
await rustRequest('navigate', { label: t.label, url: params.url });
|
|
197
|
+
await waitForPage(t.label).catch(e => log(`navigate: ${e.message}`));
|
|
198
|
+
return tabShape(t);
|
|
199
|
+
}
|
|
200
|
+
case 'screenshot': {
|
|
201
|
+
const t = byTabId(params.tabId);
|
|
202
|
+
const r = await rustRequest('screenshot', { label: t.label }, 20000) as { png: string };
|
|
203
|
+
return { dataUrl: `data:image/png;base64,${r.png}` };
|
|
204
|
+
}
|
|
205
|
+
case 'exec': {
|
|
206
|
+
const { tabId, frameId: _f, ...action } = params as Record<string, unknown> & { tabId?: number };
|
|
207
|
+
const t = byTabId(tabId);
|
|
208
|
+
const timeout = action.action === 'waitFor' ? Number(action.timeout || 5000) + 5000 : 30000;
|
|
209
|
+
return pageExec(t, action, timeout);
|
|
210
|
+
}
|
|
211
|
+
case 'setInput': {
|
|
212
|
+
// Extension does this via chrome.scripting in MAIN world; our bundle already runs in the
|
|
213
|
+
// page world, so route through the content eval with a native-setter script.
|
|
214
|
+
const t = byTabId(params.tabId);
|
|
215
|
+
const script = `(() => { const el = document.querySelector(${JSON.stringify(params.selector)});
|
|
216
|
+
if (!el) return { error: 'not found: ' + ${JSON.stringify(params.selector)} };
|
|
217
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
218
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
219
|
+
if (setter) setter.call(el, ${JSON.stringify(params.value)}); else el.value = ${JSON.stringify(params.value)};
|
|
220
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
221
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
222
|
+
return { ok: true, value: el.value }; })()`;
|
|
223
|
+
const r = await rustRequest('evalAsync', { label: t.label, js: `return JSON.stringify(${script});` }) as { json: string };
|
|
224
|
+
const inner = JSON.parse(r.json || 'null') as { error?: string; ok?: boolean } | null;
|
|
225
|
+
if (inner?.error) throw new Error(inner.error);
|
|
226
|
+
return inner;
|
|
227
|
+
}
|
|
228
|
+
case 'cdpEval': {
|
|
229
|
+
// CSP-immune eval — WKWebView callAsyncJavaScript (native compilation, awaits promises).
|
|
230
|
+
// The bridge server falls back to this when a page's CSP blocks the content-layer eval().
|
|
231
|
+
const t = byTabId(params.tabId);
|
|
232
|
+
const expression = String(params.expression || '');
|
|
233
|
+
const body = `const __r = await (${expression}); return JSON.stringify(__r === undefined ? null : __r);`;
|
|
234
|
+
const timeoutMs = Number((params as { timeoutMs?: number }).timeoutMs || 30000);
|
|
235
|
+
try {
|
|
236
|
+
const r = await rustRequest('evalAsync', { label: t.label, js: body }, timeoutMs) as { json: string };
|
|
237
|
+
return { result: JSON.parse(r.json || 'null') };
|
|
238
|
+
} catch (e) {
|
|
239
|
+
return { result: null, error: (e as Error).message };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
case 'cdpInsertText': {
|
|
243
|
+
// Controlled text insertion into a contenteditable / input WITHOUT ever submitting.
|
|
244
|
+
// The extension's real path is CDP Input.insertText (we have no CDP); this replicates it
|
|
245
|
+
// via focus + beforeinput/insertText/input events. It deliberately dispatches NO Enter/
|
|
246
|
+
// keydown and never clicks a submit control — a spike delegation auto-submitted a comment
|
|
247
|
+
// because the earlier execCommand workaround let a stray Enter through; this closes that.
|
|
248
|
+
const t = byTabId(params.tabId);
|
|
249
|
+
const selector = (params as { selector?: string }).selector;
|
|
250
|
+
const text = String((params as { text?: string }).text ?? '');
|
|
251
|
+
const clear = !!(params as { clear?: boolean }).clear;
|
|
252
|
+
const body = `
|
|
253
|
+
const el = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : 'document.activeElement'};
|
|
254
|
+
if (!el) return JSON.stringify({ error: 'insert target not found' });
|
|
255
|
+
el.focus();
|
|
256
|
+
const editable = el.isContentEditable;
|
|
257
|
+
if (${clear}) {
|
|
258
|
+
if (editable) { const r = document.createRange(); r.selectNodeContents(el); const s = getSelection(); s.removeAllRanges(); s.addRange(r); document.execCommand('delete'); }
|
|
259
|
+
else { const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const set = Object.getOwnPropertyDescriptor(proto,'value')?.set; set ? set.call(el,'') : (el.value=''); }
|
|
260
|
+
}
|
|
261
|
+
el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText', data: ${JSON.stringify(text)} }));
|
|
262
|
+
if (editable) {
|
|
263
|
+
if (!document.execCommand('insertText', false, ${JSON.stringify(text)})) el.textContent += ${JSON.stringify(text)};
|
|
264
|
+
} else {
|
|
265
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
266
|
+
const set = Object.getOwnPropertyDescriptor(proto,'value')?.set; const nv = (el.value||'') + ${JSON.stringify(text)};
|
|
267
|
+
set ? set.call(el, nv) : (el.value = nv);
|
|
268
|
+
}
|
|
269
|
+
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(text)} }));
|
|
270
|
+
return JSON.stringify({ ok: true, value: editable ? el.innerText : el.value, submitted: false });`;
|
|
271
|
+
const r = await rustRequest('evalAsync', { label: t.label, js: body }) as { json: string };
|
|
272
|
+
const out = JSON.parse(r.json || 'null') as { error?: string };
|
|
273
|
+
if (out?.error) throw new Error(out.error);
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
case 'getGroup': return { groupId: null };
|
|
277
|
+
case 'closeGroup': return { success: true };
|
|
278
|
+
case 'listExtensions': return [];
|
|
279
|
+
default:
|
|
280
|
+
throw new Error(`unsupported on appwrap-desktop: ${msg.cmd}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Bridge server WS client (the extension background's ws.ts analogue)
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
let serverWs: WebSocket | null = null;
|
|
288
|
+
function connectServer() {
|
|
289
|
+
serverWs = new WebSocket(SERVER_URL);
|
|
290
|
+
serverWs.onopen = () => {
|
|
291
|
+
log(`connected to bridge server ${SERVER_URL} as ${INSTANCE_ID}`);
|
|
292
|
+
serverWs!.send(JSON.stringify({ type: 'hello', userAgent: `appwrap-desktop-shell (Tauri/WKWebView; ${process.platform})`, instanceId: INSTANCE_ID, buildHash: 'appwrap-desktop-spike' }));
|
|
293
|
+
serverWs!.send(JSON.stringify({ type: 'ping', t: Date.now() }));
|
|
294
|
+
};
|
|
295
|
+
serverWs.onmessage = async (ev) => {
|
|
296
|
+
let msg: { id: string; cmd: string; params?: Record<string, unknown>; type?: string } | undefined;
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(String(ev.data));
|
|
299
|
+
if (parsed.type === 'pong' || parsed.type === 'reload' || parsed.type === 'recording_saved') return;
|
|
300
|
+
msg = parsed;
|
|
301
|
+
const result = await handleCommand(msg!);
|
|
302
|
+
log(`cmd=${msg!.cmd} ok`);
|
|
303
|
+
serverWs!.send(JSON.stringify({ id: msg!.id, result }));
|
|
304
|
+
} catch (e) {
|
|
305
|
+
log(`cmd=${msg?.cmd} error: ${(e as Error).message}`);
|
|
306
|
+
try { serverWs!.send(JSON.stringify({ id: msg?.id, error: (e as Error).message })); } catch { /* ignore */ }
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
serverWs.onclose = () => { log('server disconnected, retrying in 3s'); setTimeout(connectServer, 3000); };
|
|
310
|
+
serverWs.onerror = () => { /* onclose follows */ };
|
|
311
|
+
}
|
|
312
|
+
connectServer();
|
|
313
|
+
setInterval(() => {
|
|
314
|
+
if (serverWs?.readyState === WebSocket.OPEN) serverWs.send(JSON.stringify({ type: 'ping', t: Date.now() }));
|
|
315
|
+
}, 15000);
|
|
316
|
+
|
|
317
|
+
log(`page WS :${PAGE_PORT} rust control :${RUST_PORT}`);
|
|
@@ -15,10 +15,12 @@ tauri-plugin-autostart = "2"
|
|
|
15
15
|
serde = { version = "1", features = ["derive"] }
|
|
16
16
|
serde_json = "1"
|
|
17
17
|
arboard = "3"
|
|
18
|
+
libc = "0.2" # desktop.server: setsid the local-server child + SIGTERM its whole process group on teardown
|
|
18
19
|
|
|
19
20
|
# macOS oauth.authorize — ASWebAuthenticationSession (system-browser auth sheet, exact parity with the
|
|
20
21
|
# iOS shell path in runtime/app/shell/handlers-oauth.ts: auto-close on redirect, user-cancel signal).
|
|
21
22
|
[target.'cfg(target_os = "macos")'.dependencies]
|
|
23
|
+
base64 = "0.22" # SPIKE: bridge_mac screenshot PNG encoding
|
|
22
24
|
objc2 = "0.6"
|
|
23
25
|
objc2-foundation = "0.3"
|
|
24
26
|
objc2-app-kit = "0.3"
|