@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
|
@@ -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
|
|
Binary file
|
|
@@ -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
|
-
}
|