@livx.cc/appwrap 0.46.1 → 0.46.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.46.1",
3
+ "version": "0.46.3",
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",
@@ -53,6 +53,7 @@
53
53
  <category android:name="android.intent.category.DEFAULT" />
54
54
  <category android:name="android.intent.category.BROWSABLE" />
55
55
  <data android:scheme="appwrap" />
56
+ <!-- appwrap:oauth-redirect-schemes --><!-- /appwrap:oauth-redirect-schemes -->
56
57
  </intent-filter>
57
58
  </activity>
58
59
  <activity android:name="com.tns.ErrorReportActivity"/>
@@ -93,6 +93,15 @@ export class CustomWebView extends WebView {
93
93
  /** Set by the bridge before load; receives raw envelope JSON. */
94
94
  onAppwrapMessage: ((json: string) => void) | null = null;
95
95
 
96
+ // STRONG JS references to the native clients. Chromium holds the Java WebViewClient /
97
+ // WebChromeClient, but NativeScript's mark-and-sweep does NOT treat that native hold as a
98
+ // GC root — so without a JS-side reference the JS peer gets collected under memory pressure
99
+ // (e.g. heavy scrolling), and the next background-thread shouldInterceptRequest crashes with
100
+ // "Cannot find runtime for instance". Retaining here (mirrors @nativescript/core's
101
+ // `nativeView.client = client`) keeps the peer alive for the WebView's lifetime.
102
+ private webViewClient: android.webkit.WebViewClient | null = null;
103
+ private webChromeClient: android.webkit.WebChromeClient | null = null;
104
+
96
105
  // Native-view → owning CustomWebView, keyed by the Java identity hash (stable per native
97
106
  // object, robust to NS re-wrapping the arg passed into the ChromeClient callback). WeakRef
98
107
  // so a destroyed view can be GC'd; entry pruned on dispose.
@@ -131,15 +140,23 @@ export class CustomWebView extends WebView {
131
140
  wv.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER);
132
141
 
133
142
  this.installDocumentStartShim(wv);
134
- wv.setWebViewClient(createAssetServingClient());
143
+ // Retain both clients in long-lived JS fields (see field docs) so their JS peers survive GC
144
+ // for the WebView's lifetime — otherwise a background-thread shouldInterceptRequest can fire
145
+ // after the peer is collected and crash with "Cannot find runtime for instance".
146
+ this.webViewClient = createAssetServingClient();
147
+ wv.setWebViewClient(this.webViewClient);
135
148
  // Bind THIS native view to THIS instance before wiring the (shared, extend-once) client.
136
149
  CustomWebView.registry.set(wv.hashCode(), new WeakRef<CustomWebView>(this));
137
- wv.setWebChromeClient(new (getChromeClientClass())());
150
+ this.webChromeClient = new (getChromeClientClass())();
151
+ wv.setWebChromeClient(this.webChromeClient);
138
152
  }
139
153
 
140
154
  disposeNativeView(): void {
141
155
  const wv = this.android as android.webkit.WebView | undefined;
142
156
  if (wv) CustomWebView.registry.delete(wv.hashCode());
157
+ // Drop the retained clients now that the native view is going away, so their peers can be GC'd.
158
+ this.webViewClient = null;
159
+ this.webChromeClient = null;
143
160
  super.disposeNativeView();
144
161
  }
145
162
 
@@ -16,27 +16,13 @@ const RUST_PORT = Number(process.env.APPWRAP_BRIDGE_RUST_PORT || 9237);
16
16
 
17
17
  const log = (m: string, ...a: unknown[]) => console.log(`[shim] ${m}`, ...a);
18
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
- }
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';
40
26
 
41
27
  // ---------------------------------------------------------------------------
42
28
  // Rust control channel (TCP, newline-delimited JSON)
@@ -56,6 +42,9 @@ function rustRequest(op: string, params: Record<string, unknown> = {}, timeoutMs
56
42
  });
57
43
  }
58
44
 
45
+ // The product layer — driven through our transport (rustRequest) + fed page beacons via onPageMsg.
46
+ const driver = createBrowserDriver({ rustRequest, log });
47
+
59
48
  Bun.listen({
60
49
  hostname: '127.0.0.1',
61
50
  port: RUST_PORT,
@@ -75,7 +64,7 @@ Bun.listen({
75
64
  if (!line) continue;
76
65
  try {
77
66
  const msg = JSON.parse(line);
78
- if (msg.type === 'pageMsg') { onPageMsg(String(msg.payload)); continue; }
67
+ if (msg.type === 'pageMsg') { driver.onPageMsg(String(msg.payload)); continue; }
79
68
  const p = rustPending.get(msg.id);
80
69
  if (p) {
81
70
  rustPending.delete(msg.id);
@@ -91,34 +80,6 @@ Bun.listen({
91
80
  },
92
81
  });
93
82
 
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
83
  // Debug HTTP endpoint (curl a raw rust op / read page beacons)
123
84
  Bun.serve({
124
85
  hostname: '127.0.0.1',
@@ -135,152 +96,6 @@ Bun.serve({
135
96
  },
136
97
  });
137
98
 
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
99
  // ---------------------------------------------------------------------------
285
100
  // Bridge server WS client (the extension background's ws.ts analogue)
286
101
  // ---------------------------------------------------------------------------
@@ -298,7 +113,7 @@ function connectServer() {
298
113
  const parsed = JSON.parse(String(ev.data));
299
114
  if (parsed.type === 'pong' || parsed.type === 'reload' || parsed.type === 'recording_saved') return;
300
115
  msg = parsed;
301
- const result = await handleCommand(msg!);
116
+ const result = await driver.handleCommand(msg!);
302
117
  log(`cmd=${msg!.cmd} ok`);
303
118
  serverWs!.send(JSON.stringify({ id: msg!.id, result }));
304
119
  } catch (e) {
@@ -0,0 +1,120 @@
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 {
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
+ </style>
49
+ </head>
50
+ <body>
51
+ <div id="bar">
52
+ <button class="nav" id="back" title="Back">&#8249;</button>
53
+ <button class="nav" id="fwd" title="Forward">&#8250;</button>
54
+ <button class="nav" id="reload" title="Reload">&#8635;</button>
55
+ <input id="addr" type="text" spellcheck="false" autocomplete="off"
56
+ placeholder="Enter address (e.g. 127.0.0.1:3000)" />
57
+ <select id="profile" title="Profile"></select>
58
+ </div>
59
+ <script>
60
+ (function () {
61
+ var id = new URLSearchParams(location.search).get('id') || '';
62
+ function invoke(cmd, args) {
63
+ var core = window.__TAURI__ && window.__TAURI__.core;
64
+ if (!core) return Promise.reject(new Error('Tauri core unavailable'));
65
+ return core.invoke(cmd, args || {});
66
+ }
67
+
68
+ var addr = document.getElementById('addr');
69
+ var profileSel = document.getElementById('profile');
70
+
71
+ function normalizeUrl(raw) {
72
+ var s = (raw || '').trim();
73
+ if (!s) return '';
74
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) || /^about:/i.test(s)) return s;
75
+ return 'https://' + s;
76
+ }
77
+
78
+ document.getElementById('back').addEventListener('click', function () { invoke('browser_tab_back', { id: id }); });
79
+ document.getElementById('fwd').addEventListener('click', function () { invoke('browser_tab_forward', { id: id }); });
80
+ document.getElementById('reload').addEventListener('click', function () { invoke('browser_tab_reload', { id: id }); });
81
+
82
+ addr.addEventListener('keydown', function (e) {
83
+ if (e.key !== 'Enter') return;
84
+ var url = normalizeUrl(addr.value);
85
+ if (url) invoke('browser_tab_navigate', { id: id, url: url });
86
+ });
87
+
88
+ profileSel.addEventListener('change', function () {
89
+ invoke('browser_subwindow_switch_profile', { id: id, profileId: profileSel.value });
90
+ });
91
+
92
+ // Populate the profile dropdown.
93
+ invoke('browser_list_profiles').then(function (profiles) {
94
+ (profiles || ['default']).forEach(function (p) {
95
+ var opt = document.createElement('option');
96
+ opt.value = p; opt.textContent = p;
97
+ profileSel.appendChild(opt);
98
+ });
99
+ }).catch(function (e) { console.error('list profiles failed', e); });
100
+
101
+ // Keep the address bar in sync with the controlled webview (skip while the user is editing it).
102
+ var focused = false;
103
+ addr.addEventListener('focus', function () { focused = true; });
104
+ addr.addEventListener('blur', function () { focused = false; });
105
+ function poll() {
106
+ invoke('browser_tab_current_url', { id: id }).then(function (json) {
107
+ if (focused) return;
108
+ try {
109
+ var o = JSON.parse(json);
110
+ var href = o && o.href ? o.href : '';
111
+ if (href && href !== 'about:blank') addr.value = href;
112
+ } catch (_) {}
113
+ }).catch(function () {});
114
+ }
115
+ setInterval(poll, 1000);
116
+ poll();
117
+ })();
118
+ </script>
119
+ </body>
120
+ </html>