@livx.cc/appwrap 0.55.0 → 0.56.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.
Files changed (42) hide show
  1. package/package.json +2 -3
  2. package/runtime/app/shell/capabilities.manifest.ts +3 -3
  3. package/runtime/tests/bridge-response-delivery.test.ts +10 -1
  4. package/runtime/tests/env-scheme.test.ts +6 -0
  5. package/scripts/stage-assets.mjs +1 -1
  6. package/src/cli.ts +40 -570
  7. package/src/config.ts +1 -1
  8. package/src/handlers.ts +1 -1
  9. package/src/packs.ts +1 -1
  10. package/runtime-desktop/autotest/index.html +0 -77
  11. package/runtime-desktop/bridge-shim/build.sh +0 -9
  12. package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
  13. package/runtime-desktop/bridge-shim/shim.ts +0 -132
  14. package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
  15. package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
  16. package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
  17. package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
  18. package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
  19. package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
  20. package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
  21. package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
  22. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
  23. package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
  24. package/runtime-desktop/src-tauri/Cargo.toml +0 -45
  25. package/runtime-desktop/src-tauri/build.rs +0 -3
  26. package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
  27. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  28. package/runtime-desktop/src-tauri/shell_config.json +0 -10
  29. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
  30. package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
  31. package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
  32. package/runtime-desktop/src-tauri/src/main.rs +0 -1228
  33. package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
  34. package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
  35. package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
  36. package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
  37. package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
  38. package/runtime-desktop/src-tauri/src/server.rs +0 -156
  39. package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
  40. package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
  41. package/src/desktop.ts +0 -300
  42. package/src/plugin/host.ts +0 -242
@@ -1,45 +0,0 @@
1
- [package]
2
- name = "appwrap-desktop-shell"
3
- version = "0.1.0"
4
- edition = "2021"
5
-
6
- [build-dependencies]
7
- tauri-build = { version = "2", features = [] }
8
-
9
- [dependencies]
10
- # `devtools` feature so `open_devtools()`/`close_devtools()` compile in RELEASE too (in debug they're
11
- # always available); the ⌥⌘I toggle is still gated at runtime by webview_control::devtools_enabled().
12
- tauri = { version = "2", features = ["devtools"] }
13
- tauri-plugin-deep-link = "2"
14
- tauri-plugin-window-state = "2"
15
- tauri-plugin-notification = "2"
16
- tauri-plugin-autostart = "2"
17
- serde = { version = "1", features = ["derive"] }
18
- serde_json = "1"
19
- arboard = "3"
20
- libc = "0.2" # desktop.server: setsid the local-server child + SIGTERM its whole process group on teardown
21
-
22
- # macOS oauth.authorize — ASWebAuthenticationSession (system-browser auth sheet, exact parity with the
23
- # iOS shell path in runtime/app/shell/handlers-oauth.ts: auto-close on redirect, user-cancel signal).
24
- [target.'cfg(target_os = "macos")'.dependencies]
25
- # The generalized native webview primitive (per-profile store, eval, snapshot, popup, bridgeShim).
26
- # objc2* versions MUST stay identical to those below — the WKWebView pointer crosses the boundary.
27
- webview-control = { path = "../crates/webview-control" }
28
- base64 = "0.22" # SPIKE: bridge_mac screenshot PNG encoding
29
- objc2 = "0.6"
30
- objc2-foundation = "0.3"
31
- objc2-app-kit = "0.3"
32
- objc2-authentication-services = "0.3"
33
- objc2-web-kit = "0.3"
34
- objc2-local-authentication = "0.3" # Touch ID — LAContext (biometrics.available / .authenticate)
35
- block2 = "0.6"
36
- # local notifications (notifications.schedule): UNUserNotificationCenter + a delegate so a tapped
37
- # notification carrying a deepLink routes through deeplink.open (tauri-plugin-notification has no click callback).
38
- objc2-user-notifications = { version = "0.3", default-features = false, features = [
39
- "std", "block2",
40
- "UNUserNotificationCenter", "UNNotificationContent", "UNNotificationRequest",
41
- "UNNotificationResponse", "UNNotificationTrigger", "UNNotification",
42
- ] }
43
-
44
- [profile.dev]
45
- incremental = true
@@ -1,3 +0,0 @@
1
- fn main() {
2
- tauri_build::build()
3
- }
@@ -1,6 +0,0 @@
1
- {
2
- "$schema": "../gen/schemas/desktop-schema.json",
3
- "identifier": "default",
4
- "windows": ["main", "subwin-*"],
5
- "permissions": ["core:default", "notification:default"]
6
- }
@@ -1,10 +0,0 @@
1
- {
2
- "appId": "cc.livx.hellopwa.desktop",
3
- "name": "Hello PWA (desktop)",
4
- "version": "0.1.0",
5
- "title": "Hello PWA — appwrap desktop",
6
- "width": 430,
7
- "height": 800,
8
- "urlScheme": "hellowrap",
9
- "identifier": "cc.livx.hellopwa.desktop"
10
- }
@@ -1,138 +0,0 @@
1
- // macOS Touch ID / biometrics — LocalAuthentication LAContext, exact parity with the iOS shell path
2
- // (runtime/app/shell/handlers-extended.ts):
3
- // biometrics.available → { available: bool, type: 'face' | 'touch' | 'none' }
4
- // biometrics.authenticate → { success: true } OR rejects with code 'DENIED'
5
- //
6
- // Policy: LAPolicyDeviceOwnerAuthenticationWithBiometrics — biometrics ONLY, no passcode fallback.
7
- // The kit's biometrics.authenticate(reason) contract carries NO "allow passcode" flag (see
8
- // packages/native-kit/src/modules/biometrics.ts) and the iOS handler uses the WithBiometrics policy,
9
- // so we mirror that exactly. (DeviceOwnerAuthentication — biometry-or-password — is intentionally not
10
- // wired: it would over-deliver vs the mobile contract.)
11
- //
12
- // Threading: `available` (canEvaluatePolicy) is a pure query, safe off the main thread — computed on
13
- // the worker thread and delivered inline. `authenticate` is initiated on the main thread (mirrors the
14
- // other native lanes); its reply block runs on a private LA queue and delivers via the window. The
15
- // LAContext MUST stay alive for the whole evaluation (ARC would otherwise cancel it mid-prompt), so it
16
- // is parked in a static for the flight's lifetime — single-flight, like oauth/push.
17
- use std::sync::atomic::{AtomicBool, Ordering};
18
- use std::sync::Mutex;
19
-
20
- use block2::RcBlock;
21
- use objc2::rc::Retained;
22
- use objc2::runtime::Bool;
23
- use objc2_foundation::{NSError, NSString};
24
- use objc2_local_authentication::{LABiometryType, LAContext, LAPolicy};
25
- use serde_json::{json, Value};
26
-
27
- use crate::deliver;
28
-
29
- /// Human-readable diagnostic for an LAError availability code (kit shape only carries `available:bool`,
30
- /// so this is for the shell log, not the response). Codes: LocalAuthentication LAError.
31
- fn availability_reason(code: isize) -> &'static str {
32
- match code {
33
- -5 => "passcode not set",
34
- -6 => "no biometric hardware",
35
- -7 => "biometrics not enrolled",
36
- -8 => "biometrics locked out (too many attempts)",
37
- _ => "unavailable",
38
- }
39
- }
40
-
41
- fn biometry_type_str(t: LABiometryType) -> &'static str {
42
- match t {
43
- LABiometryType::FaceID => "face",
44
- LABiometryType::TouchID => "touch",
45
- _ => "none",
46
- }
47
- }
48
-
49
- /// `biometrics.available` — { available, type }. Synchronous; safe on the worker thread.
50
- pub fn available() -> Value {
51
- let ctx = unsafe { LAContext::new() };
52
- // biometryType is only populated AFTER a canEvaluatePolicy call, so evaluate first.
53
- let available = match unsafe { ctx.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics) } {
54
- Ok(()) => true,
55
- Err(e) => {
56
- let code = e.code();
57
- eprintln!("[biometrics] unavailable: {} (code {code})", availability_reason(code));
58
- false
59
- }
60
- };
61
- let ty = biometry_type_str(unsafe { ctx.biometryType() });
62
- json!({ "available": available, "type": ty })
63
- }
64
-
65
- /// Strong ref to the in-flight context, held for the evaluation's lifetime. Only ever touched here;
66
- /// the Send impl exists purely so the (framework-confined) context can live in a static Mutex.
67
- struct ActiveFlow(Option<Retained<LAContext>>);
68
- unsafe impl Send for ActiveFlow {}
69
- static ACTIVE: Mutex<ActiveFlow> = Mutex::new(ActiveFlow(None));
70
-
71
- /// Synchronous single-flight reservation, claimed on the INVOKE thread before the main-thread hop
72
- /// (mirrors main.rs OAUTH_IN_FLIGHT). Guarding on ACTIVE alone is a TOCTOU: ACTIVE is only set
73
- /// inside the run_on_main_thread closure, so two quick authenticate calls could both pass the check
74
- /// and the second context would overwrite (and drop) the first mid-prompt.
75
- static IN_FLIGHT: AtomicBool = AtomicBool::new(false);
76
-
77
- fn deliver_success(window: &tauri::WebviewWindow, id: &str) {
78
- let resp = json!({ "v": 1, "id": id, "kind": "response", "result": { "success": true } });
79
- deliver(window, &resp);
80
- }
81
-
82
- fn deliver_denied(window: &tauri::WebviewWindow, id: &str, msg: &str) {
83
- // Kit contract: authenticate rejects with 'DENIED' on user cancel/failure.
84
- let resp = json!({ "v": 1, "id": id, "kind": "response", "error": { "code": "DENIED", "message": msg } });
85
- deliver(window, &resp);
86
- }
87
-
88
- /// `biometrics.authenticate` — prompt Touch ID; reply delivered from the LA completion block.
89
- /// Single-flight: a second prompt while one is up is refused (only one LA sheet at a time).
90
- pub fn authenticate(window: tauri::WebviewWindow, id: String, reason: String) {
91
- // Atomic reservation BEFORE the main-thread hop — see IN_FLIGHT doc for why ACTIVE can't guard.
92
- if IN_FLIGHT.swap(true, Ordering::SeqCst) {
93
- return deliver_denied(&window, &id, "a biometric prompt is already in progress");
94
- }
95
- let reason = if reason.trim().is_empty() { "Authenticate".to_string() } else { reason };
96
- let w = window.clone();
97
- let fail_id = id.clone();
98
- let res = window.run_on_main_thread(move || {
99
- let ctx = unsafe { LAContext::new() };
100
-
101
- let cb_window = w.clone();
102
- let cb_id = id.clone();
103
- let block = RcBlock::new(move |success: Bool, error: *mut NSError| {
104
- // Release the parked context + reservation first — a follow-up authenticate must be
105
- // legal from here.
106
- ACTIVE.lock().unwrap().0 = None;
107
- IN_FLIGHT.store(false, Ordering::SeqCst);
108
- if success.as_bool() {
109
- eprintln!("[biometrics] authenticate succeeded");
110
- deliver_success(&cb_window, &cb_id);
111
- } else {
112
- let msg = if error.is_null() {
113
- "authentication failed".to_string()
114
- } else {
115
- unsafe { (*error).localizedDescription() }.to_string()
116
- };
117
- eprintln!("[biometrics] authenticate failed: {msg}");
118
- deliver_denied(&cb_window, &cb_id, &msg);
119
- }
120
- });
121
-
122
- // Park the context BEFORE evaluate — the reply can fire from a private queue at any moment.
123
- ACTIVE.lock().unwrap().0 = Some(ctx.clone());
124
- unsafe {
125
- ctx.evaluatePolicy_localizedReason_reply(
126
- LAPolicy::DeviceOwnerAuthenticationWithBiometrics,
127
- &NSString::from_str(&reason),
128
- &block,
129
- );
130
- }
131
- eprintln!("[biometrics] evaluatePolicy started");
132
- });
133
- if let Err(e) = res {
134
- ACTIVE.lock().unwrap().0 = None;
135
- IN_FLIGHT.store(false, Ordering::SeqCst);
136
- deliver_denied(&window, &fail_id, &format!("failed to reach main thread: {e}"));
137
- }
138
- }
@@ -1,424 +0,0 @@
1
- // Control-channel ADAPTER for the browser-bridge shim (bridge-shim/shim.ts).
2
- //
3
- // Phase 1 extraction: the WKWebView primitive (per-profile persistent+isolated stores, manual
4
- // window creation, CSP-immune eval, snapshot, the bridgeShim handler, SSO popups) now lives in the
5
- // `webview-control` crate. This file is the thin transport adapter that OWNS the bod-browser wire
6
- // format + the TCP control channel:
7
- // - dials the shim's TCP control port (line-JSON) and serves window ops,
8
- // - frames the crate's RAW page payloads into {type:"pageMsg", payload} lines,
9
- // - frames RAW eval/snapshot results into {id,result}/{id,error} responses,
10
- // - keeps the legacy tauri-window path (no profileId) for back-compat.
11
- // Env-gated: fully inert unless APPWRAP_BRIDGE_JS points at the built content bundle.
12
- use base64::Engine;
13
- use serde_json::{json, Value};
14
- use std::io::{Read, Write};
15
- use std::net::TcpStream;
16
- use std::sync::mpsc::{channel, Sender};
17
- use std::sync::Mutex;
18
- use std::time::Duration;
19
- use tauri::{AppHandle, Manager};
20
-
21
- use objc2::rc::Retained;
22
- use objc2::MainThreadMarker;
23
- use objc2_web_kit::WKWebView;
24
-
25
- // The transport-free primitive. This adapter is the ONLY place that knows the bod-browser wire
26
- // format; the crate stays automation-agnostic.
27
- use webview_control as wv;
28
-
29
- /// Build the page->shim message sink for one webview: wrap the crate's RAW payload into the
30
- /// {type:"pageMsg", payload} envelope the shim expects. This is the seam — the framing lives here,
31
- /// never in the crate.
32
- fn page_sink(tx: Sender<String>) -> wv::MessageSink {
33
- Box::new(move |raw: String| {
34
- let _ = tx.send(json!({ "type": "pageMsg", "payload": raw }).to_string());
35
- })
36
- }
37
-
38
- fn page_port() -> u16 {
39
- std::env::var("APPWRAP_BRIDGE_PAGE_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9236)
40
- }
41
-
42
- /// The live control-channel page->shim sender, set for the duration of a connected `run()`. Lets a
43
- /// webview created OUTSIDE the createWindow op (e.g. a browser sub-window in browser_tab.rs) route
44
- /// its page beacons to the SAME shim, so it registers as a bod-browser tab. None when disconnected.
45
- static PAGE_TX: Mutex<Option<Sender<String>>> = Mutex::new(None);
46
-
47
- /// Clears PAGE_TX on drop, so every `run()` return path (normal or error) leaves it None.
48
- struct PageTxGuard;
49
- impl Drop for PageTxGuard {
50
- fn drop(&mut self) {
51
- *PAGE_TX.lock().unwrap() = None;
52
- }
53
- }
54
-
55
- /// Page->shim sink for a webview created outside `createWindow` (browser sub-windows). Unlike a
56
- /// snapshot of the CURRENT PAGE_TX, this sink reads the LIVE control connection at send-time: a
57
- /// sub-window opened while the shim is disconnected (PAGE_TX == None) installs this ONE sink and its
58
- /// beacons are dropped for now, but the moment a later `run()` publishes a PAGE_TX the SAME installed
59
- /// sink starts delivering — so the window SELF-HEALS into agent-exposure on shim (re)connect, with no
60
- /// re-injection of the message handler. Framing stays identical to `page_sink`.
61
- pub fn bridge_sink() -> wv::MessageSink {
62
- Box::new(move |raw: String| {
63
- if let Some(tx) = PAGE_TX.lock().unwrap().as_ref() {
64
- let _ = tx.send(json!({ "type": "pageMsg", "payload": raw }).to_string());
65
- }
66
- })
67
- }
68
-
69
- /// Build the SAME framed init-JS `createWindow` injects, for a webview created elsewhere. Reads the
70
- /// content bundle from `APPWRAP_BRIDGE_JS`; None when the feature is off (unset/empty) or the read
71
- /// fails (logged). `label` becomes `window.__BRIDGE_LABEL` so the shim registers this webview by id.
72
- pub fn bridge_init_js(label: &str) -> Option<String> {
73
- let path = match std::env::var("APPWRAP_BRIDGE_JS") {
74
- Ok(p) if !p.is_empty() => p,
75
- _ => return None,
76
- };
77
- match std::fs::read_to_string(&path) {
78
- Ok(bundle) => Some(format!(
79
- "window.__BRIDGE_LABEL={};window.__BRIDGE_PORT={};\n{}",
80
- serde_json::to_string(&label).unwrap(), page_port(), bundle
81
- )),
82
- Err(e) => { eprintln!("[bridge-shim] bridge_init_js read {path}: {e}"); None }
83
- }
84
- }
85
- fn rust_port() -> u16 {
86
- std::env::var("APPWRAP_BRIDGE_RUST_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9237)
87
- }
88
-
89
- // ---- Agent-browser-control gate ------------------------------------------------------------------
90
- // The bridge control channel can eval arbitrary JS in any embedded tab, so it is OPT-IN and OFF by
91
- // default. It is env-gated at process launch (APPWRAP_BRIDGE_JS), but an app's opt-in typically lives
92
- // in the FE and isn't known until after launch — a chicken-and-egg. Resolution: a PERSISTENT marker
93
- // file (survives relaunch, unlike per-launch web storage). The app writes/removes the marker (e.g. via
94
- // an `app.setAgentBrowserControl` handler) and asks the user to relaunch; on the next launch
95
- // `resolve_gate()` reads the marker and, if present, populates the bridge env BEFORE `init()` — so the
96
- // bridge comes up exactly when the user opted in, and stays fully inert otherwise. The marker path is
97
- // owned by the caller (main.rs, which knows the app identifier) so this module stays app-agnostic.
98
-
99
- /// Derive the built content bundle path from the running binary (dev + bundled layouts), so callers
100
- /// don't have to know it. Returns the first candidate that exists.
101
- fn resolve_content_js() -> Option<String> {
102
- let exe = std::env::current_exe().ok()?;
103
- let mut dir = exe.parent();
104
- while let Some(d) = dir {
105
- // Dev layout: …/runtime-desktop/… has the built bundle at bridge-shim/dist/content.js.
106
- let cand = d.join("bridge-shim/dist/content.js");
107
- if cand.exists() {
108
- return Some(cand.to_string_lossy().into_owned());
109
- }
110
- // Bundled .app: content.js is staged into Contents/Resources.
111
- let res = d.join("Resources/content.js");
112
- if res.exists() {
113
- return Some(res.to_string_lossy().into_owned());
114
- }
115
- dir = d.parent();
116
- }
117
- None
118
- }
119
-
120
- /// Launch-time gate resolution. The opt-in `marker` is the SINGLE source of truth for the
121
- /// browser-control bridge: it arms iff the marker is present, regardless of any inherited env.
122
- ///
123
- /// A controlling daemon (mcp-desktop-browser) ALWAYS spawns us with `APPWRAP_BRIDGE_JS` set (it can't
124
- /// know the FE opt-in), so trusting an inherited env would let a daemon spawn arm the bridge with no
125
- /// marker — defeating the gate. Therefore: no marker → forcibly CLEAR the bridge env (`init()` then
126
- /// returns early, fully inert), no matter what we were spawned with. Marker present (opted in) → keep
127
- /// a daemon-provided env as-is (correct rendezvous ports), or populate it from the resolved content
128
- /// bundle + defaults when launched standalone. Must run before `init()` and before any embedded tab.
129
- pub fn resolve_gate(marker: &std::path::Path) {
130
- if !marker.exists() {
131
- // Not opted in — the bridge stays inert even if a spawner set the env. The marker, not the
132
- // inherited env, is authoritative for the browser-control channel.
133
- std::env::remove_var("APPWRAP_BRIDGE_JS");
134
- return;
135
- }
136
- if std::env::var("APPWRAP_BRIDGE_JS").ok().filter(|s| !s.is_empty()).is_some() {
137
- return; // opted in + env already provided (daemon-spawned) — respect its rendezvous ports
138
- }
139
- match resolve_content_js() {
140
- Some(js) => {
141
- std::env::set_var("APPWRAP_BRIDGE_JS", js);
142
- if std::env::var("APPWRAP_BRIDGE_RUST_PORT").is_err() {
143
- std::env::set_var("APPWRAP_BRIDGE_RUST_PORT", "3843");
144
- }
145
- if std::env::var("APPWRAP_BRIDGE_PAGE_PORT").is_err() {
146
- std::env::set_var("APPWRAP_BRIDGE_PAGE_PORT", "3842");
147
- }
148
- eprintln!("[bridge-shim] agent browser control ENABLED via opt-in marker");
149
- }
150
- None => eprintln!("[bridge-shim] opt-in marker present but content.js not found — bridge stays off"),
151
- }
152
- }
153
-
154
- pub fn init(app: AppHandle) {
155
- let js_path = match std::env::var("APPWRAP_BRIDGE_JS") {
156
- Ok(p) if !p.is_empty() => p,
157
- _ => return, // feature off
158
- };
159
- std::thread::spawn(move || loop {
160
- match TcpStream::connect(("127.0.0.1", rust_port())) {
161
- Ok(stream) => {
162
- eprintln!("[bridge-shim] control connected");
163
- run(&app, stream, &js_path);
164
- eprintln!("[bridge-shim] control disconnected");
165
- }
166
- Err(_) => {}
167
- }
168
- std::thread::sleep(Duration::from_secs(1));
169
- });
170
- }
171
-
172
- fn run(app: &AppHandle, mut stream: TcpStream, js_path: &str) {
173
- let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
174
- let (tx, rx) = channel::<String>();
175
- // Publish this connection's sender so sub-window webviews (created outside createWindow) can route
176
- // their page beacons here; the guard clears it on ANY return below (disconnect/error).
177
- *PAGE_TX.lock().unwrap() = Some(tx.clone());
178
- let _tx_guard = PageTxGuard;
179
- // Self-heal sub-windows opened BEFORE this connect: their dynamic sink can now deliver, but a
180
- // document already loaded won't re-fire its `register` beacon on its own. Nudge every open
181
- // sub-window webview to re-post `register` so it (re)appears as a bod-browser tab on this shim.
182
- crate::browser_tab::reregister_subwindows(app);
183
- let mut acc = String::new();
184
- let mut buf = [0u8; 65536];
185
- loop {
186
- while let Ok(line) = rx.try_recv() {
187
- if stream.write_all(line.as_bytes()).and_then(|_| stream.write_all(b"\n")).is_err() {
188
- return;
189
- }
190
- }
191
- match stream.read(&mut buf) {
192
- Ok(0) => return,
193
- Ok(n) => {
194
- acc.push_str(&String::from_utf8_lossy(&buf[..n]));
195
- while let Some(idx) = acc.find('\n') {
196
- let line = acc[..idx].trim().to_string();
197
- acc.drain(..=idx);
198
- if line.is_empty() { continue; }
199
- match serde_json::from_str::<Value>(&line) {
200
- Ok(v) => handle_op(app, &tx, v, js_path),
201
- Err(e) => eprintln!("[bridge-shim] bad line: {e}"),
202
- }
203
- }
204
- }
205
- Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
206
- Err(_) => return,
207
- }
208
- }
209
- }
210
-
211
- fn respond(tx: &Sender<String>, id: &str, result: Result<Value, String>) {
212
- let msg = match result {
213
- Ok(r) => json!({ "id": id, "result": r }),
214
- Err(e) => json!({ "id": id, "error": e }),
215
- };
216
- let _ = tx.send(msg.to_string());
217
- }
218
-
219
- fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
220
- let id = v["id"].as_str().unwrap_or_default().to_string();
221
- let op = v["op"].as_str().unwrap_or_default().to_string();
222
- eprintln!("[bridge-shim] op={op} id={id}");
223
- let app2 = app.clone();
224
- let tx2 = tx.clone();
225
- match op.as_str() {
226
- "createWindow" => {
227
- let label = v["label"].as_str().unwrap_or_default().to_string();
228
- let url = v["url"].as_str().unwrap_or("about:blank").to_string();
229
- let profile_id = v["profileId"].as_str().unwrap_or_default().to_string();
230
- // Re-read the bundle each time — a rebuilt content.js applies to the next window (dev loop).
231
- let init_js = match std::fs::read_to_string(js_path) {
232
- Ok(bundle) => format!(
233
- "window.__BRIDGE_LABEL={};window.__BRIDGE_PORT={};\n{}",
234
- serde_json::to_string(&label).unwrap(), page_port(), bundle
235
- ),
236
- Err(e) => { respond(tx, &id, Err(format!("read bundle {js_path}: {e}"))); return; }
237
- };
238
- // Profile windows take the crate's MANUAL creation path (per-profile persistent store).
239
- // Windows without a profileId keep the original tauri path (back-compat).
240
- if !profile_id.is_empty() {
241
- let idc = id.clone();
242
- let init_js2 = init_js.clone();
243
- let r = app.run_on_main_thread(move || {
244
- let mtm = MainThreadMarker::new().expect("run_on_main_thread is main");
245
- let res = wv::create_profile_window(mtm, &label, &url, &profile_id, &init_js2, page_sink(tx2.clone()))
246
- .map(|c| json!({ "ok": true, "label": label, "profileId": profile_id, "storeUuid": c.store_uuid, "persistent": c.persistent }));
247
- respond(&tx2, &idc, res);
248
- });
249
- if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
250
- return;
251
- }
252
- let idc = id.clone();
253
- let r = app.run_on_main_thread(move || {
254
- let parsed = match tauri::Url::parse(&url) {
255
- Ok(u) => u,
256
- Err(e) => { respond(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
257
- };
258
- let r = tauri::WebviewWindowBuilder::new(&app2, &label, tauri::WebviewUrl::External(parsed))
259
- .title(&label)
260
- .inner_size(1100.0, 800.0)
261
- // Safari-normalized UA at BUILD time (same constraint as the main window).
262
- .user_agent(crate::popup_mac::SAFARI_UA)
263
- .initialization_script(&init_js)
264
- .build();
265
- // Install the page->shim message handler on the fresh webview (main thread), plus the
266
- // SAME popup machinery the main window gets (SSO window.open child windows).
267
- if let Ok(w) = &r {
268
- let tx4 = tx2.clone();
269
- let _ = w.with_webview(move |pw| unsafe {
270
- crate::popup_mac::install(pw.inner() as _, false);
271
- let mtm = MainThreadMarker::new().expect("with_webview runs on main");
272
- wv::install_message_handler(pw.inner() as *mut WKWebView, mtm, page_sink(tx4));
273
- });
274
- }
275
- respond(&tx2, &idc, r.map(|_| json!({ "ok": true, "label": label })).map_err(|e| e.to_string()));
276
- });
277
- if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
278
- }
279
- "closeWindow" => {
280
- let label = v["label"].as_str().unwrap_or_default().to_string();
281
- if wv::profile_webview(&label).is_some() {
282
- let idc = id.clone();
283
- let r = app.run_on_main_thread(move || {
284
- let ok = wv::close(&label);
285
- respond(&tx2, &idc, if ok { Ok(json!({ "ok": true })) } else { Err(format!("no window {label}")) });
286
- });
287
- if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
288
- return;
289
- }
290
- match app.get_webview_window(&label) {
291
- Some(w) => respond(tx, &id, w.close().map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
292
- None => respond(tx, &id, Err(format!("no window {label}"))),
293
- }
294
- }
295
- "navigate" => {
296
- let label = v["label"].as_str().unwrap_or_default().to_string();
297
- let url = v["url"].as_str().unwrap_or_default().to_string();
298
- let idc = id.clone();
299
- let r = app.run_on_main_thread(move || {
300
- if let Some(wk) = wv::profile_webview(&label) {
301
- let res = unsafe { wv::navigate(Retained::as_ptr(&wk) as *mut WKWebView, &url) };
302
- respond(&tx2, &idc, res.map(|_| json!({ "ok": true })));
303
- return;
304
- }
305
- let parsed = match tauri::Url::parse(&url) {
306
- Ok(u) => u,
307
- Err(e) => { respond(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
308
- };
309
- match app2.get_webview_window(&label) {
310
- Some(w) => respond(&tx2, &idc, w.navigate(parsed).map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
311
- None => respond(&tx2, &idc, Err(format!("no window {label}"))),
312
- }
313
- });
314
- if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
315
- }
316
- "screenshot" => {
317
- let label = v["label"].as_str().unwrap_or_default().to_string();
318
- let idc = id.clone();
319
- let r = app.run_on_main_thread(move || {
320
- if let Some(wk) = wv::profile_webview(&label) {
321
- let tx3 = tx2.clone();
322
- let id2 = idc.clone();
323
- unsafe { wv::snapshot(Retained::as_ptr(&wk) as *mut WKWebView, move |r| respond_png(&tx3, &id2, r)) };
324
- return;
325
- }
326
- let Some(w) = app2.get_webview_window(&label) else {
327
- respond(&tx2, &idc, Err(format!("no window {label}")));
328
- return;
329
- };
330
- let tx3 = tx2.clone();
331
- let id2 = idc.clone();
332
- let r = w.with_webview(move |pw| unsafe {
333
- wv::snapshot(pw.inner() as *mut WKWebView, move |r| respond_png(&tx3, &id2, r))
334
- });
335
- if let Err(e) = r { respond(&tx2, &idc, Err(e.to_string())); }
336
- });
337
- if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
338
- }
339
- "evalJs" => {
340
- // Fire-and-forget JS injection into a window (debug aid + transport fallback probe).
341
- let label = v["label"].as_str().unwrap_or_default().to_string();
342
- let js = v["js"].as_str().unwrap_or_default().to_string();
343
- if wv::profile_webview(&label).is_some() {
344
- let idc = id.clone();
345
- let r = app.run_on_main_thread(move || {
346
- if let Some(wk) = wv::profile_webview(&label) {
347
- unsafe { wv::inject_script(Retained::as_ptr(&wk) as *mut WKWebView, &js) };
348
- respond(&tx2, &idc, Ok(json!({ "ok": true })));
349
- } else {
350
- respond(&tx2, &idc, Err(format!("no window {label}")));
351
- }
352
- });
353
- if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
354
- return;
355
- }
356
- match app.get_webview_window(&label) {
357
- Some(w) => respond(tx, &id, w.eval(&js).map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
358
- None => respond(tx, &id, Err(format!("no window {label}"))),
359
- }
360
- }
361
- "evalAsync" => {
362
- // CSP-immune eval with a real (awaited) return value — callAsyncJavaScript. `js` is a
363
- // FUNCTION BODY (use `return`). The crate delivers the RAW return string; we frame {json}.
364
- let label = v["label"].as_str().unwrap_or_default().to_string();
365
- let js = v["js"].as_str().unwrap_or_default().to_string();
366
- let idc = id.clone();
367
- let r = app.run_on_main_thread(move || {
368
- if let Some(wk) = wv::profile_webview(&label) {
369
- let tx3 = tx2.clone();
370
- let id2 = idc.clone();
371
- unsafe { wv::eval_async(Retained::as_ptr(&wk) as *mut WKWebView, js.clone(), move |r| respond_eval(&tx3, &id2, r)) };
372
- return;
373
- }
374
- let Some(w) = app2.get_webview_window(&label) else {
375
- respond(&tx2, &idc, Err(format!("no window {label}")));
376
- return;
377
- };
378
- let tx3 = tx2.clone();
379
- let id2 = idc.clone();
380
- let js2 = js.clone();
381
- let r = w.with_webview(move |pw| unsafe {
382
- wv::eval_async(pw.inner() as *mut WKWebView, js2, move |r| respond_eval(&tx3, &id2, r));
383
- });
384
- if let Err(e) = r { respond(&tx2, &idc, Err(e.to_string())); }
385
- });
386
- if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
387
- }
388
- "userClose" => {
389
- // Verification aid: drive the red-button path WITHOUT touching the registry, so the
390
- // windowWillClose delegate is the sole remover — proves user-close hygiene headlessly.
391
- let label = v["label"].as_str().unwrap_or_default().to_string();
392
- let idc = id.clone();
393
- let r = app.run_on_main_thread(move || {
394
- let ok = wv::user_close(&label);
395
- respond(&tx2, &idc, if ok { Ok(json!({ "ok": true })) } else { Err(format!("no window {label}")) });
396
- });
397
- if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
398
- }
399
- "listWindows" => {
400
- // Closed windows are filtered by the crate; actual reaping (which drops objc objects) is
401
- // deferred to createWindow, which runs on the main thread.
402
- let mut labels: Vec<String> = app.webview_windows().keys().cloned().collect();
403
- let profiles: Vec<Value> = wv::list_webviews()
404
- .into_iter()
405
- .map(|info| {
406
- labels.push(info.label.clone());
407
- json!({ "label": info.label, "profileId": info.profile_id })
408
- })
409
- .collect();
410
- respond(tx, &id, Ok(json!({ "labels": labels, "profiles": profiles })));
411
- }
412
- _ => respond(tx, &id, Err(format!("unknown op: {op}"))),
413
- }
414
- }
415
-
416
- /// Frame a RAW eval return string into the {json} envelope the shim reads (`r.json`).
417
- fn respond_eval(tx: &Sender<String>, id: &str, r: Result<String, String>) {
418
- respond(tx, id, r.map(|out| json!({ "json": out })));
419
- }
420
-
421
- /// Base64-encode the crate's RAW PNG bytes and frame into the {png} envelope.
422
- fn respond_png(tx: &Sender<String>, id: &str, r: Result<Vec<u8>, String>) {
423
- respond(tx, id, r.map(|bytes| json!({ "png": base64::engine::general_purpose::STANDARD.encode(bytes) })));
424
- }