@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.
Files changed (43) 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/templates/ci/fastlane/Fastfile +47 -6
  11. package/runtime-desktop/autotest/index.html +0 -77
  12. package/runtime-desktop/bridge-shim/build.sh +0 -9
  13. package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
  14. package/runtime-desktop/bridge-shim/shim.ts +0 -132
  15. package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
  16. package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
  17. package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
  18. package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
  19. package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
  20. package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
  21. package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
  22. package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
  23. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
  24. package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
  25. package/runtime-desktop/src-tauri/Cargo.toml +0 -45
  26. package/runtime-desktop/src-tauri/build.rs +0 -3
  27. package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
  28. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  29. package/runtime-desktop/src-tauri/shell_config.json +0 -10
  30. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
  31. package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
  32. package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
  33. package/runtime-desktop/src-tauri/src/main.rs +0 -1228
  34. package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
  35. package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
  36. package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
  37. package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
  38. package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
  39. package/runtime-desktop/src-tauri/src/server.rs +0 -156
  40. package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
  41. package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
  42. package/src/desktop.ts +0 -300
  43. package/src/plugin/host.ts +0 -242
@@ -1,1228 +0,0 @@
1
- // appwrap desktop shell (spike) — Tauri chassis, appwrap protocol v1 surface.
2
- //
3
- // The whole bridge is ONE generic command: the init script injects the same
4
- // `window.appwrapNative.postMessage(json)` transport the Android shell exposes,
5
- // so the unmodified @livx.cc/native-kit AppwrapAdapter detects it and speaks
6
- // v1 envelopes. Responses/events go back via `window.__appwrapDeliver(json)`,
7
- // same as mobile. Handlers below are the desktop analogue of runtime/app/shell/handlers.ts.
8
- #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
9
-
10
- use serde_json::{json, Value};
11
- use std::collections::HashMap;
12
- use std::sync::atomic::{AtomicBool, Ordering};
13
- use std::sync::{Mutex, OnceLock};
14
- use tauri::Manager;
15
-
16
- #[cfg(target_os = "macos")]
17
- mod biometrics_mac;
18
- #[cfg(target_os = "macos")]
19
- mod bridge_mac;
20
- mod browser_tab;
21
- #[cfg(target_os = "macos")]
22
- mod notifications_mac;
23
- #[cfg(target_os = "macos")]
24
- mod oauth_mac;
25
- #[cfg(target_os = "macos")]
26
- mod plugin_host;
27
- #[cfg(target_os = "macos")]
28
- mod popup_mac;
29
- #[cfg(target_os = "macos")]
30
- mod push_mac;
31
- #[cfg(target_os = "macos")]
32
- mod server;
33
- #[cfg(target_os = "macos")]
34
- mod sidecar;
35
-
36
- static STORAGE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
37
-
38
- /// App handle, set once in setup() — lets off-main-thread handlers (e.g. `toast.show`) reach
39
- /// plugin APIs like tauri-plugin-notification, which need a `Manager` to resolve their state.
40
- static APP: OnceLock<tauri::AppHandle> = OnceLock::new();
41
-
42
- /// Single in-flight `oauth.authorize` guard (mirrors the mobile shells' one-session model): a second
43
- /// authorize while one is pending → NATIVE_ERROR. The ASWebAuthenticationSession completion handler
44
- /// (oauth_mac) resolves/cancels the flow and clears this.
45
- static OAUTH_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
46
-
47
- /// Cold-vs-warm deep-link state, under ONE lock: `handshaken` flips on the first `app.handshake`
48
- /// (after which links are WARM → `deeplink.open` events); `pending` holds a link that arrived
49
- /// before it (cold launch FROM a link), carried back as `deepLink` in the handshake response
50
- /// (mobile parity) so the PWA can route before first paint. A single mutex — not an atomic flag
51
- /// beside a mutex — so the handshake's read-flag+drain and on_open_url's check-then-stash can't
52
- /// interleave and strand a link that arrives at the exact handshake instant.
53
- struct DeepLinkState { handshaken: bool, pending: Option<String> }
54
- static DEEPLINK: Mutex<DeepLinkState> = Mutex::new(DeepLinkState { handshaken: false, pending: None });
55
-
56
- /// Server-mode (`desktop.server`) deep-link delivery. The page is a plain web app (not a native-kit
57
- /// consumer), so instead of the native `deeplink.open` handshake we deliver deep links to it directly:
58
- /// COLD (link arrived before the page loaded) → appended to the navigation URL as `?__appwrap_deeplink=`
59
- /// so the page reads it on first load; WARM (already loaded) → a `window` CustomEvent `appwrap:deeplink`.
60
- /// `loaded` flips true when the boot thread navigates to the server URL (drains `pending` atomically).
61
- struct ServerNav { loaded: bool, pending: Option<String> }
62
- static SERVER_NAV: Mutex<ServerNav> = Mutex::new(ServerNav { loaded: false, pending: None });
63
-
64
- /// Percent-encode a string for use as a URL query-param value (RFC3986 unreserved set kept verbatim).
65
- fn percent_encode(s: &str) -> String {
66
- let mut out = String::new();
67
- for b in s.bytes() {
68
- match b {
69
- b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
70
- _ => out.push_str(&format!("%{b:02X}")),
71
- }
72
- }
73
- out
74
- }
75
-
76
-
77
- /// App identity + window shape, stamped by `appwrap dev|build desktop` from the appwrap config into
78
- /// `shell_config.json` (embedded at compile time). De-hardcodes what the spike baked in. Mirrors
79
- /// `DesktopShellConfig` in packages/appwrap-cli/src/desktop.ts.
80
- #[derive(serde::Deserialize)]
81
- #[serde(rename_all = "camelCase")]
82
- struct ShellConfig {
83
- app_id: String,
84
- name: String,
85
- version: String,
86
- title: String,
87
- width: f64,
88
- height: f64,
89
- url_scheme: String,
90
- identifier: String,
91
- /// Persist + restore window size/position across launches (tauri-plugin-window-state). When
92
- /// false the plugin isn't registered, so the configured width/height always apply.
93
- #[serde(default = "default_true")]
94
- restore_window_state: bool,
95
- /// Append `prompt=select_account` to Google `signInWithPopup` OAuth popup URLs (see popup_mac).
96
- #[serde(default)]
97
- google_account_picker: bool,
98
- /// Launch on login (tauri-plugin-autostart, macOS LaunchAgent). On setup the shell enables the
99
- /// login item when true and explicitly disables it when false — so flipping the config off
100
- /// removes the item on the next launch.
101
- #[serde(default)]
102
- autostart: bool,
103
- /// Absolute path to the app's custom-handlers TS file (stamped by the CLI). When non-empty the shell
104
- /// spawns a `bun <path>` sidecar at setup and routes methods the sidecar advertises to it (see
105
- /// sidecar.rs). Empty/absent → the feature is fully inert (no sidecar).
106
- #[serde(default)]
107
- handlers: String,
108
- /// Absolute path to the Bun binary to spawn the sidecar with (the CLI stamps its own
109
- /// `process.execPath`). A GUI-launched .app inherits the launchd PATH (no ~/.bun/bin), so a
110
- /// bare `bun` lookup would fail there; sidecar.rs falls back to `bun` when this is empty/gone.
111
- #[serde(default)]
112
- handlers_runtime: String,
113
- /// Absolute path to the bun-built multiplexed plugin-host bundle (src/plugin/host.ts). Empty →
114
- /// no plugins configured; the shell skips spawning the host. Stamped by `regeneratePlugins`.
115
- #[serde(default)]
116
- plugin_host: String,
117
- /// Resolved + bun-built plugins the host loads at boot (`[{namespace, attachTo, bundlePath}]`),
118
- /// passed through to the host process verbatim. Empty when none.
119
- #[serde(default)]
120
- plugins: Vec<serde_json::Value>,
121
- /// True when the CLI resolved a real signing identity + macOS provisioning profile for this
122
- /// build — i.e. the .app carries the aps-environment entitlement, so APNs registration can
123
- /// actually succeed. Gates push.register and the handshake's push capability (adhoc → 'none').
124
- #[serde(default)]
125
- push_signed: bool,
126
- /// Local-server mode: when present the shell boots this command, waits for its port, then
127
- /// navigates the window to `http://localhost:<port>` (instead of loading the bundled dist), and
128
- /// SIGTERMs its process group on close. Absent → bundled-PWA load. See server.rs.
129
- #[serde(default)]
130
- server: Option<ServerConfig>,
131
- }
132
-
133
- /// Resolved local-server config (mirrors `DesktopShellConfig.server` in desktop.ts; `command`/`cwd`/
134
- /// `path` are absolute, stamped by the CLI).
135
- #[derive(serde::Deserialize, Clone)]
136
- #[serde(rename_all = "camelCase")]
137
- struct ServerConfig {
138
- command: String,
139
- #[serde(default)]
140
- args: Vec<String>,
141
- #[serde(default)]
142
- cwd: String,
143
- /// Non-empty → capture the URL the server prints on the first stdout line containing this marker
144
- /// (tracks a dynamic port). Empty → use the fixed `port` below.
145
- #[serde(default)]
146
- url_marker: String,
147
- #[serde(default)]
148
- port: u16,
149
- /// Load over https (vs http) in fixed-`port` mode. True when the server serves TLS (e.g. vite + mkcert).
150
- #[serde(default)]
151
- https: bool,
152
- /// Reserved: readiness currently uses a TCP-accept probe (sufficient for vite/preview), so the
153
- /// path isn't hit yet — kept in the wire config for a future HTTP health probe.
154
- #[serde(default)]
155
- #[allow(dead_code)]
156
- health_path: String,
157
- ready_timeout_ms: u64,
158
- #[serde(default)]
159
- path: String,
160
- }
161
-
162
- fn default_true() -> bool { true }
163
-
164
- fn shell() -> &'static ShellConfig {
165
- static CFG: OnceLock<ShellConfig> = OnceLock::new();
166
- CFG.get_or_init(|| {
167
- serde_json::from_str(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/shell_config.json")))
168
- .expect("invalid shell_config.json")
169
- })
170
- }
171
-
172
- // Expose `window.appwrapNative` (page -> native bridge). Prefer the WKScriptMessageHandler transport
173
- // (`webkit.messageHandlers.appwrapNative`, installed on the main webview) — it works from ANY origin
174
- // including a REMOTE server-loaded page, where Tauri command IPC is ACL-blocked. Fall back to Tauri
175
- // IPC for app:// pages if the handler isn't present.
176
- const INIT_JS: &str = r#"
177
- (() => {
178
- try {
179
- var mh = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.appwrapNative;
180
- if (mh) { window.appwrapNative = { postMessage: function (json) { mh.postMessage(json); } }; return; }
181
- var i = window.__TAURI_INTERNALS__;
182
- if (i && i.invoke) { window.appwrapNative = { postMessage: function (json) { i.invoke('appwrap_invoke', { envelope: json }); } }; }
183
- } catch (e) { /* no bridge available on this origin */ }
184
- })();
185
- "#;
186
-
187
- /// Build a JS snippet that swaps the boot splash into an error state with `msg` (server mode). Escapes
188
- /// the message for a single-quoted JS string literal.
189
- fn splash_error_js(msg: &str) -> String {
190
- let esc = msg
191
- .replace('\\', "\\\\")
192
- .replace('\'', "\\'")
193
- .replace(['\n', '\r'], " ");
194
- format!(
195
- "(function(){{var s=document.getElementById('spin');if(s)s.remove();\
196
- var m=document.getElementById('m');if(m)m.textContent='Server didn\\'t start';\
197
- var e=document.getElementById('e');if(e)e.textContent='{esc}';}})()"
198
- )
199
- }
200
-
201
- type HandlerResult = Result<Value, (&'static str, String)>;
202
-
203
- /// spawn() without wait() leaks a zombie per call; reap in a detached thread.
204
- fn spawn_reaped(mut cmd: std::process::Command) -> Result<(), (&'static str, String)> {
205
- let mut child = cmd.spawn().map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
206
- std::thread::spawn(move || { let _ = child.wait(); });
207
- Ok(())
208
- }
209
-
210
- /// Show a real OS notification (tauri-plugin-notification). Checks/asks permission first and errors
211
- /// (not panics) on a missing app handle, denied permission, or delivery failure — the caller decides
212
- /// how to degrade. On macOS the plugin routes through the app bundle's identity, so Notification
213
- /// Center attributes it to the shell; unbundled `cargo run` may fail to deliver (no bundle id) → logged.
214
- fn show_notification(title: &str, body: &str) -> Result<(), String> {
215
- use tauri::plugin::PermissionState;
216
- use tauri_plugin_notification::NotificationExt;
217
- let app = APP.get().ok_or("app handle not ready")?;
218
- let notifier = app.notification();
219
- let state = match notifier.permission_state().map_err(|e| e.to_string())? {
220
- PermissionState::Prompt | PermissionState::PromptWithRationale => {
221
- notifier.request_permission().map_err(|e| e.to_string())?
222
- }
223
- s => s,
224
- };
225
- if state != PermissionState::Granted {
226
- return Err(format!("permission not granted ({state:?})"));
227
- }
228
- notifier
229
- .builder()
230
- .title(title)
231
- .body(body)
232
- .show()
233
- .map_err(|e| e.to_string())
234
- }
235
-
236
- /// Set (Some(n)) or clear (None) the Dock-icon badge on the main window. Resolves the window via the
237
- /// stashed app handle so the worker-thread handlers can reach it; the tauri call posts to the event
238
- /// loop, so it's safe off the main thread.
239
- fn set_badge(count: Option<i64>) -> Result<(), (&'static str, String)> {
240
- let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
241
- let window = app
242
- .get_webview_window("main")
243
- .ok_or(("NATIVE_ERROR", "no main window".to_string()))?;
244
- window
245
- .set_badge_count(count)
246
- .map_err(|e| ("NATIVE_ERROR", e.to_string()))
247
- }
248
-
249
- /// Request user attention on the main window (macOS: bounce the Dock icon). `critical` bounces until
250
- /// the app gains focus; otherwise a single informational bounce. Like `set_badge`, the underlying
251
- /// `request_user_attention` posts to the event loop (send_user_message), so it's safe off the main
252
- /// thread — no `run_on_main_thread` needed.
253
- fn request_attention(critical: bool) -> Result<(), (&'static str, String)> {
254
- let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
255
- let window = app
256
- .get_webview_window("main")
257
- .ok_or(("NATIVE_ERROR", "no main window".to_string()))?;
258
- let kind = if critical {
259
- tauri::UserAttentionType::Critical
260
- } else {
261
- tauri::UserAttentionType::Informational
262
- };
263
- window
264
- .request_user_attention(Some(kind))
265
- .map_err(|e| ("NATIVE_ERROR", e.to_string()))
266
- }
267
-
268
- /// Method-namespace prefixes served by the built-in `handle()` below (kept next to it — add the
269
- /// prefix here when you add a new built-in family). sidecar::init REJECTS any sidecar claim under
270
- /// these, so an app handler can never shadow a built-in (e.g. a custom `storage.get`).
271
- pub const BUILTIN_METHOD_PREFIXES: &[&str] = &[
272
- "app.", "biometrics.", "browser.", "clipboard.", "deeplink.", "device.", "network.",
273
- "notifications.", "oauth.", "push.", "storage.", "toast.", "ui.",
274
- ];
275
-
276
- fn handle(method: &str, params: &Value) -> HandlerResult {
277
- match method {
278
- "app.handshake" => {
279
- // One critical section: flip to warm AND drain any cold link together (see DEEPLINK).
280
- let cold_link = {
281
- let mut dl = DEEPLINK.lock().unwrap();
282
- dl.handshaken = true;
283
- dl.pending.take()
284
- };
285
- let mut resp = json!({
286
- "protocol": 1,
287
- "platform": "desktop",
288
- "app": {
289
- "id": shell().app_id,
290
- "name": shell().name,
291
- "version": shell().version,
292
- "loader": "app"
293
- },
294
- // Explicit 'none' = veto (kit will NOT web-upgrade it): things a desktop
295
- // genuinely lacks. Keys OMITTED here degrade to the kit's WebAdapter
296
- // (share, geo, speech, media, ...) per the hybrid-fallback merge.
297
- "capabilities": {
298
- "clipboard": "native",
299
- "toast": "native",
300
- "device": "native",
301
- "storage": "native",
302
- "app": "native",
303
- "browser": "native",
304
- "network": "native",
305
- "ui": "native",
306
- "deeplinks": "native",
307
- "oauth": "native",
308
- // Touch ID via LocalAuthentication. The kit's WebAdapter stub answers available:
309
- // false / authenticate→UNSUPPORTED (web-adapter.ts), so OMITTING the key would peg
310
- // the module to a permanent no-op. We DO have a real native lane, so 'native' is the
311
- // honest value — and it stays honest whether or not THIS Mac has enrolled hardware
312
- // (biometrics.available then returns available:false, a valid native response).
313
- "biometrics": "native",
314
- // Dock-icon badge is native (notifications.setBadge/clear → NSDockTile); this is the
315
- // key `kit.app.badgeCapability` reads. `notifications` is 'native' too: schedule/
316
- // pending/requestPermission are intercepted below and served by notifications_mac
317
- // (UNUserNotificationCenter — the only lane whose tap callback can route a stamped
318
- // deepLink; the web Notification API fallback never fires since these never return
319
- // UNSUPPORTED).
320
- "notifications": "native",
321
- "badge": "native",
322
- "haptics": "none",
323
- // 'native' ONLY when the CLI stamped push_signed (signed .app with an embedded
324
- // profile backing aps-environment) — an adhoc build can never get an APNs token.
325
- "push": if shell().push_signed { "native" } else { "none" },
326
- "motion": "none"
327
- }
328
- });
329
- // Cold launch FROM a deep link: hand the launching URL back synchronously (mobile parity —
330
- // see `deepLink` on the kit's Handshake type) so the PWA routes before first paint.
331
- if let Some(link) = cold_link {
332
- resp["deepLink"] = json!(link);
333
- }
334
- Ok(resp)
335
- }
336
- "app.environment" => Ok(json!({
337
- "platform": "desktop",
338
- "os": std::env::consts::OS,
339
- "arch": std::env::consts::ARCH,
340
- "debug": cfg!(debug_assertions)
341
- })),
342
- "app.canOpenUrl" => Ok(json!({ "ok": params["url"].as_str().is_some() })),
343
- "app.openUrl" | "browser.open" => {
344
- let url = params["url"].as_str().ok_or(("NATIVE_ERROR", "missing url".to_string()))?;
345
- if !(url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")) {
346
- return Err(("DENIED", format!("scheme not allowed: {url}")));
347
- }
348
- let mut cmd = std::process::Command::new("open");
349
- cmd.arg(url);
350
- spawn_reaped(cmd)?;
351
- Ok(json!({ "ok": true }))
352
- }
353
- // Bounce the Dock icon to request attention (macOS). `critical` (default false) bounces until
354
- // the app regains focus; otherwise a single bounce. No kit capability key advertises this —
355
- // apps call it directly via kit.invoke('app.requestAttention'). Routes to request_user_attention
356
- // which posts to the event loop, so it's thread-safe from this worker thread.
357
- "app.requestAttention" => {
358
- let critical = params["critical"].as_bool().unwrap_or(false);
359
- request_attention(critical)?;
360
- Ok(json!({ "ok": true }))
361
- }
362
- "app.openSettings" => {
363
- let mut cmd = std::process::Command::new("open");
364
- cmd.arg("x-apple.systempreferences:");
365
- spawn_reaped(cmd)?;
366
- Ok(json!({ "ok": true }))
367
- }
368
- // Persist the agent-browser-control opt-in (the security gate for the bridge control channel).
369
- // Writes/removes the marker `bridge_mac::resolve_gate` reads at launch; takes effect on the
370
- // next relaunch (the bridge env is read once at startup), so signal `requiresRelaunch`.
371
- "app.setAgentBrowserControl" => {
372
- let enabled = params["enabled"].as_bool().unwrap_or(false);
373
- #[cfg(target_os = "macos")]
374
- {
375
- let path = agent_browser_control_marker();
376
- if enabled {
377
- if let Some(parent) = path.parent() {
378
- std::fs::create_dir_all(parent).map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
379
- }
380
- std::fs::write(&path, b"1").map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
381
- } else if path.exists() {
382
- std::fs::remove_file(&path).map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
383
- }
384
- }
385
- Ok(json!({ "ok": true, "enabled": enabled, "requiresRelaunch": true }))
386
- }
387
- "network.status" => Ok(json!({ "online": true, "type": "wifi" })),
388
- "ui.safeArea" => Ok(json!({ "top": 0, "right": 0, "bottom": 0, "left": 0 })),
389
- "ui.alert" | "ui.confirm" => {
390
- let message = params["message"].as_str().unwrap_or("").replace('"', "\\\"");
391
- let title = params["title"].as_str().unwrap_or(&shell().name).replace('"', "\\\"");
392
- let buttons = if method == "ui.confirm" { "{\"Cancel\", \"OK\"}" } else { "{\"OK\"}" };
393
- let script = format!(
394
- "display dialog \"{message}\" with title \"{title}\" buttons {buttons} default button -1"
395
- );
396
- let out = std::process::Command::new("osascript")
397
- .args(["-e", &script])
398
- .output()
399
- .map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
400
- // osascript exits 1 on Cancel; that's a `false` confirm, not an error.
401
- let confirmed = out.status.success();
402
- Ok(json!({ "ok": true, "confirmed": confirmed }))
403
- }
404
- "device.info" => Ok(json!({
405
- "platform": "desktop",
406
- "os": std::env::consts::OS,
407
- "arch": std::env::consts::ARCH,
408
- "model": "mac",
409
- })),
410
- "clipboard.copy" => {
411
- let text = params["text"].as_str().unwrap_or_default().to_string();
412
- arboard::Clipboard::new()
413
- .and_then(|mut c| c.set_text(text))
414
- .map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
415
- Ok(json!({ "ok": true }))
416
- }
417
- "clipboard.read" => {
418
- let text = arboard::Clipboard::new()
419
- .and_then(|mut c| c.get_text())
420
- .unwrap_or_default();
421
- Ok(json!({ "text": text }))
422
- }
423
- "toast.show" => {
424
- let message = params["message"]
425
- .as_str()
426
- .or_else(|| params["text"].as_str())
427
- .unwrap_or("");
428
- // Real OS notification via tauri-plugin-notification — proper Notification Center
429
- // identity from the bundle id (no shell-escaping / osascript hop). Graceful degrade:
430
- // a denied permission or delivery failure logs and still returns ok (a toast is
431
- // best-effort UX, never a hard error to the caller).
432
- if let Err(e) = show_notification(&shell().name, message) {
433
- eprintln!("[bridge] toast.show notification skipped: {e}");
434
- }
435
- Ok(json!({ "ok": true }))
436
- }
437
- // Dock-icon badge via the tauri window badge API (macOS NSDockTile). setBadge with count>0
438
- // shows the number; count 0 (and `clear`) removes it. schedule/pending/requestPermission are
439
- // NOT implemented here → they return UNSUPPORTED below and the kit degrades them to its
440
- // WebAdapter (Notification API). `set_badge_count` posts to the event loop (thread-safe from
441
- // this worker thread). `clear` is badge-only on desktop (no delivered-notification store).
442
- "notifications.setBadge" => {
443
- let count = params["count"].as_i64().unwrap_or(0);
444
- set_badge(if count > 0 { Some(count) } else { None })?;
445
- Ok(json!({ "ok": true }))
446
- }
447
- "notifications.clear" => {
448
- set_badge(None)?;
449
- Ok(json!({ "ok": true }))
450
- }
451
- m if m.starts_with("storage.") => storage_handle(m, params),
452
- #[cfg(target_os = "macos")]
453
- m if m.starts_with("browser.window.") => browser_window_handle(m, params),
454
- #[cfg(target_os = "macos")]
455
- m if m.starts_with("browser.tab.") => browser_tab_handle(m, params),
456
- _ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
457
- }
458
- }
459
-
460
- /// browser.tab.* — create/drive an EMBEDDED browser tab from a served PWA, mounted `ChildOf` a
461
- /// front-end-reserved pane NSView (the flexlayout tab region). Mirrors `browser.window.*` but for
462
- /// the in-layout pane surface: the FE tracks the pane's DOM rect and pushes `setRect` on
463
- /// resize/scroll. Runs on the bridge worker thread; the browser_tab entry points marshal to main.
464
- #[cfg(target_os = "macos")]
465
- fn browser_tab_handle(method: &str, params: &Value) -> HandlerResult {
466
- let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
467
- let need_id = || {
468
- params["id"]
469
- .as_str()
470
- .map(str::to_string)
471
- .ok_or(("NATIVE_ERROR", "missing id".to_string()))
472
- };
473
- match method {
474
- "browser.tab.create" => {
475
- let id = need_id()?;
476
- let url = params["url"].as_str().unwrap_or("about:blank").to_string();
477
- let profile = params["profileId"].as_str().unwrap_or("default").to_string();
478
- let id = browser_tab::tab_create(app.clone(), id, url, profile, params["rect"].clone())
479
- .map_err(|e| ("NATIVE_ERROR", e))?;
480
- Ok(json!({ "id": id }))
481
- }
482
- "browser.tab.setRect" => {
483
- browser_tab::tab_set_rect(app.clone(), need_id()?, params["rect"].clone())
484
- .map_err(|e| ("NATIVE_ERROR", e))?;
485
- Ok(json!({ "ok": true }))
486
- }
487
- "browser.tab.navigate" => {
488
- let url = params["url"].as_str().ok_or(("NATIVE_ERROR", "missing url".to_string()))?.to_string();
489
- browser_tab::tab_navigate(app.clone(), need_id()?, url).map_err(|e| ("NATIVE_ERROR", e))?;
490
- Ok(json!({ "ok": true }))
491
- }
492
- "browser.tab.setMount" => {
493
- let mode = params["mode"].as_str().ok_or(("NATIVE_ERROR", "missing mode".to_string()))?.to_string();
494
- browser_tab::tab_set_mount(app.clone(), need_id()?, mode).map_err(|e| ("NATIVE_ERROR", e))?;
495
- Ok(json!({ "ok": true }))
496
- }
497
- "browser.tab.close" => {
498
- browser_tab::tab_close(app.clone(), need_id()?).map_err(|e| ("NATIVE_ERROR", e))?;
499
- Ok(json!({ "ok": true }))
500
- }
501
- "browser.tab.back" | "browser.tab.forward" | "browser.tab.reload" => {
502
- let op = &method["browser.tab.".len()..];
503
- let op = match op { "back" => "back", "forward" => "forward", _ => "reload" };
504
- browser_tab::tab_nav(app.clone(), need_id()?, op).map_err(|e| ("NATIVE_ERROR", e))?;
505
- Ok(json!({ "ok": true }))
506
- }
507
- "browser.tab.currentUrl" => {
508
- let s = browser_tab::tab_current_url(app.clone(), need_id()?).map_err(|e| ("NATIVE_ERROR", e))?;
509
- // `s` is a JSON string {"href":..,"title":..}; embed as an object for the FE.
510
- Ok(serde_json::from_str::<Value>(&s).unwrap_or_else(|_| json!({})))
511
- }
512
- "browser.tab.eval" => {
513
- let js = params["js"].as_str().ok_or(("NATIVE_ERROR", "missing js".to_string()))?.to_string();
514
- let s = browser_tab::tab_eval(app.clone(), need_id()?, js).map_err(|e| ("NATIVE_ERROR", e))?;
515
- Ok(json!({ "result": s }))
516
- }
517
- "browser.tab.listProfiles" => Ok(json!({ "profiles": webview_control::list_profiles() })),
518
- "browser.tab.switchProfile" => {
519
- // Reuse switch_profile: closes the embedded webview + recreates it ChildOf the SAME pane
520
- // container with the new profile's data store (page reset to about:blank; caller re-navigates
521
- // to the profile's remembered url). Container/position is preserved (safe-area-correct).
522
- let profile = params["profileId"].as_str()
523
- .ok_or(("NATIVE_ERROR", "missing profileId".to_string()))?.to_string();
524
- browser_tab::switch_subwindow_profile(app.clone(), need_id()?, profile)
525
- .map_err(|e| ("NATIVE_ERROR", e))?;
526
- Ok(json!({ "ok": true }))
527
- }
528
- _ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
529
- }
530
- }
531
-
532
- /// browser.window.* — spawn/control app-owned browser sub-windows from a served PWA (which has no
533
- /// injected `__TAURI__`, so it can't call the Tauri commands directly). Runs on the bridge worker
534
- /// thread; the browser_tab entry points marshal to the main thread themselves.
535
- #[cfg(target_os = "macos")]
536
- fn browser_window_handle(method: &str, params: &Value) -> HandlerResult {
537
- let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
538
- match method {
539
- "browser.window.open" => {
540
- let profile = params["profileId"].as_str().unwrap_or("default").to_string();
541
- let id = browser_tab::open_subwindow(app.clone(), profile).map_err(|e| ("NATIVE_ERROR", e))?;
542
- Ok(json!({ "id": id }))
543
- }
544
- "browser.window.listProfiles" => Ok(json!({ "profiles": webview_control::list_profiles() })),
545
- "browser.window.switchProfile" => {
546
- let id = params["id"].as_str().ok_or(("NATIVE_ERROR", "missing id".to_string()))?.to_string();
547
- let profile = params["profileId"].as_str().ok_or(("NATIVE_ERROR", "missing profileId".to_string()))?.to_string();
548
- browser_tab::switch_subwindow_profile(app.clone(), id, profile).map_err(|e| ("NATIVE_ERROR", e))?;
549
- Ok(json!({ "ok": true }))
550
- }
551
- _ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
552
- }
553
- }
554
-
555
- /// Opt-in marker for the agent-browser-control bridge gate: ~/Library/Application
556
- /// Support/<identifier>/agent-browser-control.enabled. Presence = enabled (read by
557
- /// bridge_mac::resolve_gate at launch; written/removed by `app.setAgentBrowserControl`).
558
- fn agent_browser_control_marker() -> std::path::PathBuf {
559
- let home = std::env::var("HOME").unwrap_or_default();
560
- std::path::Path::new(&home)
561
- .join("Library/Application Support")
562
- .join(&shell().identifier)
563
- .join("agent-browser-control.enabled")
564
- }
565
-
566
- /// Persisted-store file: ~/Library/Application Support/<identifier>/storage.json (macOS app-data dir).
567
- fn storage_path() -> std::path::PathBuf {
568
- let home = std::env::var("HOME").unwrap_or_default();
569
- std::path::Path::new(&home)
570
- .join("Library/Application Support")
571
- .join(&shell().identifier)
572
- .join("storage.json")
573
- }
574
-
575
- fn load_store() -> HashMap<String, String> {
576
- std::fs::read_to_string(storage_path())
577
- .ok()
578
- .and_then(|s| serde_json::from_str(&s).ok())
579
- .unwrap_or_default()
580
- }
581
-
582
- fn save_store(store: &HashMap<String, String>) -> Result<(), String> {
583
- let path = storage_path();
584
- if let Some(dir) = path.parent() {
585
- std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
586
- }
587
- let data = serde_json::to_string(store).map_err(|e| e.to_string())?;
588
- std::fs::write(&path, data).map_err(|e| e.to_string())
589
- }
590
-
591
- /// storage.* — persisted to a JSON file in the macOS app-data dir (write-through, in-memory cache).
592
- /// Note secure.* rides the SAME plaintext file: desktop parity is "persistent", not encrypted (a
593
- /// Keychain-backed secure lane is out of scope for phase-0).
594
- fn storage_handle(method: &str, params: &Value) -> HandlerResult {
595
- let mut guard = STORAGE.lock().unwrap();
596
- let store = guard.get_or_insert_with(load_store);
597
- let key = params["key"].as_str().unwrap_or_default();
598
- let persist = |store: &HashMap<String, String>| -> HandlerResult {
599
- save_store(store).map_err(|e| ("NATIVE_ERROR", e))?;
600
- Ok(json!({ "ok": true }))
601
- };
602
- match method {
603
- "storage.get" | "storage.secure.get" => Ok(json!({ "value": store.get(key) })),
604
- "storage.set" | "storage.secure.set" => {
605
- let value = params["value"].as_str().unwrap_or_default().to_string();
606
- store.insert(key.to_string(), value);
607
- persist(store)
608
- }
609
- "storage.remove" | "storage.secure.remove" => {
610
- store.remove(key);
611
- persist(store)
612
- }
613
- "storage.clear" => {
614
- store.clear();
615
- persist(store)
616
- }
617
- _ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
618
- }
619
- }
620
-
621
- #[tauri::command]
622
- fn appwrap_invoke(window: tauri::WebviewWindow, envelope: String) {
623
- let req: Value = match serde_json::from_str(&envelope) {
624
- Ok(v) => v,
625
- Err(_) => return,
626
- };
627
- if req["kind"] != "request" {
628
- return;
629
- }
630
- let id = req["id"].as_str().unwrap_or_default().to_string();
631
- let method = req["method"].as_str().unwrap_or_default().to_string();
632
- eprintln!("[bridge] {id} {method}");
633
- let params = req["params"].clone();
634
- // oauth.authorize replies asynchronously from the ASWebAuthenticationSession completion handler
635
- // (must start on the main thread; the sheet stays up for minutes) — not from the worker thread.
636
- if method == "oauth.authorize" {
637
- let url = params["url"].as_str().unwrap_or_default().to_string();
638
- let scheme = params["callbackScheme"].as_str().unwrap_or_default().to_string();
639
- let ephemeral = params["ephemeral"].as_bool().unwrap_or(false);
640
- if url.is_empty() || scheme.is_empty() {
641
- deliver_error(&window, &id, "NATIVE_ERROR", "oauth.authorize: empty url or callbackScheme");
642
- return;
643
- }
644
- if OAUTH_IN_FLIGHT.swap(true, Ordering::SeqCst) {
645
- deliver_error(&window, &id, "NATIVE_ERROR", "oauth.authorize: a flow is already in progress");
646
- return;
647
- }
648
- #[cfg(target_os = "macos")]
649
- oauth_mac::start(window, id, url, scheme, ephemeral);
650
- #[cfg(not(target_os = "macos"))]
651
- {
652
- let _ = (url, scheme, ephemeral);
653
- OAUTH_IN_FLIGHT.store(false, Ordering::SeqCst);
654
- deliver_error(&window, &id, "UNSUPPORTED", "oauth.authorize not implemented on this desktop OS");
655
- }
656
- return;
657
- }
658
- // push.register/unregister reply asynchronously from the APNs delegate callbacks (main thread) —
659
- // and are only honest on a signed build (push_signed): adhoc has no aps entitlement, so
660
- // registerForRemoteNotifications would just fail. Mirrors the mobile shell's envelope.
661
- if method == "push.register" || method == "push.unregister" {
662
- if !shell().push_signed {
663
- deliver_error(&window, &id, "UNSUPPORTED",
664
- "push needs a signed desktop build (set teamId/APPWRAP_TEAM_ID with an installed macOS provisioning profile)");
665
- return;
666
- }
667
- #[cfg(target_os = "macos")]
668
- {
669
- if method == "push.register" { push_mac::register(window, id); }
670
- else { push_mac::unregister(window, id); }
671
- }
672
- #[cfg(not(target_os = "macos"))]
673
- deliver_error(&window, &id, "UNSUPPORTED", "push not implemented on this desktop OS");
674
- return;
675
- }
676
- // biometrics.* — LocalAuthentication. `available` is a synchronous query; `authenticate` shows the
677
- // Touch ID sheet and replies asynchronously from the LA completion block (main thread). Both live
678
- // here (not in handle()) so the sheet starts off-worker-thread and the reply rides the window.
679
- if method.starts_with("biometrics.") {
680
- #[cfg(target_os = "macos")]
681
- {
682
- match method.as_str() {
683
- "biometrics.available" => {
684
- let resp = json!({ "v": 1, "id": id, "kind": "response", "result": biometrics_mac::available() });
685
- deliver(&window, &resp);
686
- }
687
- "biometrics.authenticate" => {
688
- let reason = params["reason"].as_str().unwrap_or_default().to_string();
689
- biometrics_mac::authenticate(window, id, reason);
690
- }
691
- _ => deliver_error(&window, &id, "UNSUPPORTED", &format!("{method} not implemented on desktop")),
692
- }
693
- }
694
- #[cfg(not(target_os = "macos"))]
695
- deliver_error(&window, &id, "UNSUPPORTED", "biometrics not implemented on this desktop OS");
696
- return;
697
- }
698
- // notifications.requestPermission / .schedule / .pending reply asynchronously from
699
- // UNUserNotificationCenter completion blocks (started on the main thread) — so they intercept here,
700
- // like biometrics/oauth. setBadge/clear are synchronous and fall through to handle() below.
701
- #[cfg(target_os = "macos")]
702
- if matches!(method.as_str(), "notifications.requestPermission" | "notifications.schedule" | "notifications.pending") {
703
- match method.as_str() {
704
- "notifications.requestPermission" => notifications_mac::request_permission(window, id),
705
- "notifications.schedule" => notifications_mac::schedule(window, id, params),
706
- "notifications.pending" => notifications_mac::pending(window, id),
707
- _ => unreachable!(),
708
- }
709
- return;
710
- }
711
- // Off the main thread: handlers may block (clipboard, subprocess).
712
- std::thread::spawn(move || {
713
- // The app's own url scheme is an INTERNAL deep link (mobile-shell parity):
714
- // loop it back as a `deeplink.open` event instead of shelling out to `open`.
715
- let scheme = shell().url_scheme.as_str();
716
- if !scheme.is_empty()
717
- && (method == "app.openUrl" || method == "browser.open")
718
- && params["url"].as_str().is_some_and(|u| u.starts_with(&format!("{scheme}://")))
719
- {
720
- let event = json!({ "v": 1, "kind": "event", "event": "deeplink.open",
721
- "payload": { "url": params["url"] } });
722
- deliver(&window, &event);
723
- let resp = json!({ "v": 1, "id": id, "kind": "response", "result": { "ok": true } });
724
- deliver(&window, &resp);
725
- return;
726
- }
727
- // App-custom methods: forward to the Bun sidecar (desktop.handlers) if it advertised this
728
- // method. Built-in collisions are ENFORCED away: sidecar::init drops any claim under
729
- // BUILTIN_METHOD_PREFIXES (below), so a custom `storage.get` can never shadow handle().
730
- // The sidecar keeps its own (dynamic) error codes — so build the envelope here rather than
731
- // routing through handle()'s &'static-str-coded HandlerResult.
732
- #[cfg(target_os = "macos")]
733
- if sidecar::claims(&method) {
734
- let resp = match sidecar::request(&method, &params) {
735
- Ok(result) => json!({ "v": 1, "id": id, "kind": "response", "result": result }),
736
- Err((code, message)) => json!({
737
- "v": 1, "id": id, "kind": "response",
738
- "error": { "code": code, "message": message }
739
- }),
740
- };
741
- deliver(&window, &resp);
742
- return;
743
- }
744
- // Plugin handler methods (cfg.plugins): the sidecar-compatible RPC path, routed AFTER the
745
- // sidecar so a `desktop.handlers` claim always wins for the same name. Same envelope shape.
746
- #[cfg(target_os = "macos")]
747
- if plugin_host::claims(&method) {
748
- let resp = match plugin_host::request(&method, &params) {
749
- Ok(result) => json!({ "v": 1, "id": id, "kind": "response", "result": result }),
750
- Err((code, message)) => json!({
751
- "v": 1, "id": id, "kind": "response",
752
- "error": { "code": code, "message": message }
753
- }),
754
- };
755
- deliver(&window, &resp);
756
- return;
757
- }
758
- let resp = match handle(&method, &params) {
759
- Ok(result) => json!({ "v": 1, "id": id, "kind": "response", "result": result }),
760
- Err((code, message)) => json!({
761
- "v": 1, "id": id, "kind": "response",
762
- "error": { "code": code, "message": message }
763
- }),
764
- };
765
- deliver(&window, &resp);
766
- });
767
- }
768
-
769
- /// Error response envelope (same shape as the inline error json in appwrap_invoke).
770
- fn deliver_error(window: &tauri::WebviewWindow, id: &str, code: &str, message: &str) {
771
- let resp = json!({
772
- "v": 1, "id": id, "kind": "response",
773
- "error": { "code": code, "message": message }
774
- });
775
- deliver(window, &resp);
776
- }
777
-
778
- /// Route an incoming deep link — from the OS (`on_open_url`) OR a tapped local notification carrying
779
- /// one (notifications_mac) — through the SAME warm/cold path: warm (post-handshake) → emit a
780
- /// `deeplink.open` event; pre-handshake → stash the FIRST url as `pending` (drained into the handshake
781
- /// response so the PWA routes before first paint). Reaches the main window via the stashed AppHandle,
782
- /// so it's safe to call from any thread. DRY single source for both entry points.
783
- pub fn route_deeplink(url: &str) {
784
- // Server-mode apps get direct web delivery (see ServerNav), not the native-kit handshake path.
785
- #[cfg(target_os = "macos")]
786
- if shell().server.is_some() {
787
- let deliver_now = {
788
- let mut nav = SERVER_NAV.lock().unwrap();
789
- if nav.loaded {
790
- true
791
- } else {
792
- nav.pending = Some(url.to_string()); // cold → boot thread appends to the nav URL
793
- false
794
- }
795
- };
796
- if deliver_now {
797
- if let Some(window) = APP.get().and_then(|app| app.get_webview_window("main")) {
798
- let esc = url.replace('\\', "\\\\").replace('\'', "\\'");
799
- let _ = window.eval(&format!(
800
- "window.dispatchEvent(new CustomEvent('appwrap:deeplink',{{detail:'{esc}'}}))"
801
- ));
802
- }
803
- }
804
- return;
805
- }
806
- let warm = {
807
- let mut dl = DEEPLINK.lock().unwrap();
808
- if !dl.handshaken && dl.pending.is_none() {
809
- dl.pending = Some(url.to_string());
810
- }
811
- dl.handshaken
812
- };
813
- if warm {
814
- if let Some(window) = APP.get().and_then(|app| app.get_webview_window("main")) {
815
- let event = json!({ "v": 1, "kind": "event", "event": "deeplink.open",
816
- "payload": { "url": url } });
817
- deliver(&window, &event);
818
- }
819
- }
820
- }
821
-
822
- fn deliver(window: &tauri::WebviewWindow, envelope: &Value) {
823
- let js = format!(
824
- "window.__appwrapDeliver({})",
825
- serde_json::to_string(&envelope.to_string()).unwrap()
826
- );
827
- let _ = window.eval(&js);
828
- }
829
-
830
- fn main() {
831
- // No `.menu(...)` override: Tauri v2 installs the default macOS menu (App/Edit/Window),
832
- // whose Edit item carries the Cut/Copy/Paste/Select-All shortcuts the WebView needs — so
833
- // Cmd+C/V/X/A work in inputs with zero shell code. Suppressing it would break those bindings.
834
- let mut builder = tauri::Builder::default()
835
- .plugin(tauri_plugin_deep_link::init())
836
- .plugin(tauri_plugin_notification::init())
837
- // Launch-on-login. LaunchAgent (default) writes a per-user LaunchAgent plist — works for the
838
- // adhoc-signed .app (no Developer ID needed). The setup hook below flips the item on/off to
839
- // match the config so turning `autostart` off actually removes it.
840
- .plugin(tauri_plugin_autostart::init(
841
- tauri_plugin_autostart::MacosLauncher::LaunchAgent,
842
- None,
843
- ));
844
- // Window size/position persistence across launches. The plugin auto-restores on window-ready
845
- // and saves on close/move/resize; skipping registration (restoreWindowState:false) means the
846
- // configured width/height always apply. Registered BEFORE the window is built so its
847
- // on_window_ready restore hook fires for it.
848
- if shell().restore_window_state {
849
- builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
850
- }
851
- builder
852
- .invoke_handler(tauri::generate_handler![
853
- appwrap_invoke,
854
- browser_tab::browser_tab_create,
855
- browser_tab::browser_tab_set_rect,
856
- browser_tab::browser_tab_set_mount,
857
- browser_tab::browser_tab_navigate,
858
- browser_tab::browser_tab_close,
859
- browser_tab::browser_subwindow_open,
860
- browser_tab::browser_subwindow_switch_profile,
861
- browser_tab::browser_tab_back,
862
- browser_tab::browser_tab_forward,
863
- browser_tab::browser_tab_reload,
864
- browser_tab::browser_tab_current_url,
865
- browser_tab::browser_list_profiles,
866
- ])
867
- // Popups are plain NSWindows tauri doesn't know about; with the main (only tauri) window
868
- // closed, tauri exits the app — but tear popups down explicitly so no dangling windows or
869
- // live child webviews outlive the shell during teardown (or if the exit is ever prevented).
870
- .on_window_event(|_window, _event| {
871
- #[cfg(target_os = "macos")]
872
- if _window.label() == "main"
873
- && matches!(_event, tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed)
874
- {
875
- popup_mac::close_all();
876
- // Kill the handlers sidecar so the Bun process never outlives the shell.
877
- sidecar::shutdown();
878
- // Tell plugins the main window is gone, then kill the plugin host (never outlives the shell).
879
- plugin_host::on_window_closed("main");
880
- plugin_host::shutdown();
881
- // NB: server teardown runs on RunEvent::Exit (below), NOT here — a blocking kill in
882
- // this close handler would starve tauri-plugin-window-state's own save-on-close.
883
- }
884
- })
885
- .setup(|app| {
886
- // Stash the app handle so off-main-thread handlers can reach plugin state (notifications).
887
- let _ = APP.set(app.handle().clone());
888
- // Native "Browser ▸ New Browser Window" menu — every appwrap desktop app gets the
889
- // app-owned browser sub-window affordance. We APPEND to the DEFAULT macOS menu (built via
890
- // Menu::default) so Edit's Cut/Copy/Paste/Select-All stay intact; suppressing them would
891
- // break Cmd+C/V in inputs. The menu event opens a sub-window for the "default" profile.
892
- {
893
- use tauri::menu::{Menu, MenuItem, Submenu};
894
- let handle = app.handle();
895
- match Menu::default(handle).and_then(|menu| {
896
- let new_win = MenuItem::with_id(
897
- handle, "browser_new_window", "New Browser Window", true,
898
- Some("CmdOrCtrl+Shift+N"),
899
- )?;
900
- let browser = Submenu::with_id_and_items(
901
- handle, "browser_menu", "Browser", true, &[&new_win],
902
- )?;
903
- menu.append(&browser)?;
904
- // View ▸ Toggle DevTools (⌥⌘I). Opens/closes Tauri's devtools on the focused
905
- // window; the handler no-ops in release unless APPWRAP_DEVTOOLS is set
906
- // (devtools_enabled), matching the Safari-inspectable gate.
907
- // Reload App (⌘R) — reloads the shell's main webview (like Slack's ⌘R), handy after
908
- // a hot-reload hiccup or a stuck load.
909
- let reload_app = MenuItem::with_id(
910
- handle, "reload_app", "Reload App", true, Some("CmdOrCtrl+R"),
911
- )?;
912
- let toggle_devtools = MenuItem::with_id(
913
- handle, "toggle_devtools", "Toggle DevTools", true,
914
- Some("Alt+CmdOrCtrl+I"),
915
- )?;
916
- // Menu::default already installs a macOS "View" submenu — APPEND to it rather than
917
- // adding a SECOND "View" (which produced two View menus, with DevTools stranded in
918
- // the second one). Fall back to creating one only if the default lacks it.
919
- let existing_view = menu.items()?.into_iter().find_map(|it| {
920
- it.as_submenu()
921
- .filter(|s| s.text().map(|t| t == "View").unwrap_or(false))
922
- .cloned()
923
- });
924
- match existing_view {
925
- Some(view) => {
926
- view.append(&reload_app)?;
927
- view.append(&toggle_devtools)?;
928
- }
929
- None => {
930
- let view = Submenu::with_id_and_items(
931
- handle, "view_menu", "View", true, &[&reload_app, &toggle_devtools],
932
- )?;
933
- menu.append(&view)?;
934
- }
935
- }
936
- Ok(menu)
937
- }) {
938
- Ok(menu) => {
939
- if let Err(e) = app.set_menu(menu) {
940
- eprintln!("[browser] set_menu failed: {e}");
941
- }
942
- }
943
- Err(e) => eprintln!("[browser] build Browser menu failed: {e}"),
944
- }
945
- app.on_menu_event(|app, event| {
946
- match event.id().as_ref() {
947
- "reload_app" => {
948
- // Reload the shell's main webview (⌘R). Full app reload — re-fetches the UI
949
- // (served or app://) from scratch, like Slack's ⌘R.
950
- if let Some(win) = app.get_webview_window("main") {
951
- // Tear down embedded browser panes FIRST: reload() never runs React's
952
- // unmount cleanup, so `browser.tab.close` is never called and the panes
953
- // orphan (leaked WKWebViews + visual mixing). Runs inline on the main
954
- // thread (on_menu_event is on main); the reloaded FE re-creates its tabs.
955
- browser_tab::close_all_embedded();
956
- let _ = win.eval("window.location.reload()");
957
- }
958
- }
959
- "browser_new_window" => {
960
- // MUST spawn off the main thread: on_menu_event runs on the main thread, and
961
- // browser_subwindow_open dispatches work back onto the main thread + blocks on
962
- // it — calling it inline here would deadlock the app (the classic sync-on-main
963
- // freeze). Spawn the async command onto the runtime so the main thread stays free.
964
- let app = app.clone();
965
- tauri::async_runtime::spawn(async move {
966
- if let Err(e) = browser_tab::browser_subwindow_open(app, Some("default".to_string())).await {
967
- eprintln!("[browser] open sub-window (menu) failed: {e}");
968
- }
969
- });
970
- }
971
- "toggle_devtools" => {
972
- // Gate at runtime: inert in a release build unless APPWRAP_DEVTOOLS is set
973
- // (same policy as Safari-inspectable); always on in debug.
974
- #[cfg(target_os = "macos")]
975
- if !webview_control::devtools_enabled() {
976
- eprintln!("[devtools] toggle ignored (set APPWRAP_DEVTOOLS=1 to enable in release)");
977
- return;
978
- }
979
- // Toggle on the focused webview window (sub-windows are their own
980
- // WebviewWindows), falling back to main.
981
- let win = app.webview_windows()
982
- .into_values()
983
- .find(|w| w.is_focused().unwrap_or(false))
984
- .or_else(|| app.get_webview_window("main"));
985
- if let Some(win) = win {
986
- if win.is_devtools_open() {
987
- win.close_devtools();
988
- } else {
989
- win.open_devtools();
990
- }
991
- }
992
- }
993
- _ => {}
994
- }
995
- });
996
- }
997
- // SPIKE: browser-bridge window-ops backend — inert unless APPWRAP_BRIDGE_JS is set.
998
- // resolve_gate() first populates the bridge env from the persisted opt-in marker (an app's
999
- // "agent browser control" toggle writes it via `app.setAgentBrowserControl`), so the
1000
- // channel comes up only when the user opted in; no marker → env stays unset → init()
1001
- // returns early (fully inert).
1002
- #[cfg(target_os = "macos")]
1003
- bridge_mac::resolve_gate(&agent_browser_control_marker());
1004
- #[cfg(target_os = "macos")]
1005
- bridge_mac::init(app.handle().clone());
1006
- // Per-app custom handlers: spawn the Bun sidecar when configured (absolute path stamped by
1007
- // the CLI). No-op / degrades to inert when empty, on spawn failure, or on a missing `ready`.
1008
- // NOTE: init blocks setup for up to READY_TIMEOUT (5s, bounded) — intentional: sidecar
1009
- // methods must be claimable before the webview's first invoke can race them.
1010
- #[cfg(target_os = "macos")]
1011
- if !shell().handlers.is_empty() {
1012
- sidecar::init(&shell().handlers, &shell().handlers_runtime);
1013
- }
1014
- // Multiplexed plugin host (cfg.plugins): spawn the Bun host over a Unix socket and block
1015
- // (≤5s) on its `ready` before the window's first invoke can race a plugin handler. Additive
1016
- // to the sidecar — inert when no plugins are configured. Reuses handlersRuntime (the same
1017
- // stamped bun binary). See plugin_host.rs.
1018
- #[cfg(target_os = "macos")]
1019
- if !shell().plugin_host.is_empty() && !shell().plugins.is_empty() {
1020
- let plugins_json = serde_json::to_string(&shell().plugins).unwrap_or_else(|_| "[]".into());
1021
- plugin_host::init(
1022
- app.handle().clone(),
1023
- &shell().plugin_host,
1024
- &shell().handlers_runtime,
1025
- &plugins_json,
1026
- );
1027
- }
1028
- // Launch-on-login: reconcile the login item to the config every launch, so turning
1029
- // `autostart` off (or on) takes effect on the next run. Best-effort — a failure to
1030
- // enable/disable the LaunchAgent shouldn't abort startup.
1031
- {
1032
- use tauri_plugin_autostart::ManagerExt;
1033
- let al = app.autolaunch();
1034
- let r = if shell().autostart { al.enable() } else { al.disable() };
1035
- if let Err(e) = r {
1036
- eprintln!("[bridge] autostart reconcile ({}) failed: {e}", shell().autostart);
1037
- }
1038
- }
1039
- let restore = shell().restore_window_state;
1040
- let win_builder = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
1041
- .title(shell().title.as_str())
1042
- .inner_size(shell().width, shell().height)
1043
- // wry installs a NATIVE OS drag-drop handler on the webview by default, which INTERCEPTS
1044
- // all drag events — so in-page HTML5 drag-and-drop (dragover/drop) never reaches the DOM
1045
- // (probe: dragstart fires but dragover never does). That silently breaks any web UI that
1046
- // relies on HTML5 DnD (e.g. flexlayout tab reorder/dock). Disable it so the page gets DnD.
1047
- .disable_drag_drop_handler()
1048
- .initialization_script(INIT_JS);
1049
- // With state restore on, the plugin repositions/resizes AFTER creation — build hidden so
1050
- // the restore lands before first paint (no visible jump), then show below.
1051
- let win_builder = if restore { win_builder.visible(false) } else { win_builder };
1052
- // Safari-normalized UA must be set at BUILD time — the first document snapshots
1053
- // navigator.userAgent before setup() runs (probe: install()-time setCustomUserAgent
1054
- // only applied from the next navigation).
1055
- #[cfg(target_os = "macos")]
1056
- let win_builder = win_builder.user_agent(popup_mac::SAFARI_UA);
1057
- win_builder.build()?;
1058
- // Popup-window support (window.open → real child NSWindow; Firebase signInWithPopup)
1059
- // + Safari UA normalization. with_webview runs its closure on the main thread.
1060
- #[cfg(target_os = "macos")]
1061
- if let Some(window) = app.get_webview_window("main") {
1062
- let picker = shell().google_account_picker;
1063
- let app_for_sink = app.handle().clone();
1064
- window.with_webview(move |w| {
1065
- popup_mac::install(w.inner() as _, picker);
1066
- // Safari Web Inspector on the main Tauri window's WKWebView (debug always;
1067
- // release only under APPWRAP_DEVTOOLS). Runs on the main thread (with_webview).
1068
- unsafe { webview_control::set_inspectable(w.inner() as *mut _) };
1069
- // Native bridge for SERVED (remote-origin) pages: Tauri command IPC is ACL-blocked
1070
- // for remote origins, and ws/http/custom-scheme are mixed-content-blocked from https
1071
- // — but a WKScriptMessageHandler works from ANY origin. Served pages reach native via
1072
- // `webkit.messageHandlers.appwrapNative.postMessage(envelope)`, routed through the
1073
- // same dispatch as the Tauri-IPC `appwrap_invoke` (off-thread + response delivery).
1074
- let mtm = objc2::MainThreadMarker::new().expect("with_webview runs on main");
1075
- let sink: webview_control::MessageSink = Box::new(move |envelope: String| {
1076
- if let Some(win) = app_for_sink.get_webview_window("main") {
1077
- appwrap_invoke(win, envelope);
1078
- }
1079
- });
1080
- unsafe {
1081
- webview_control::install_named_message_handler(
1082
- w.inner() as *mut _, mtm, "appwrapNative", sink,
1083
- );
1084
- }
1085
- })?;
1086
- }
1087
- // Reveal after the state plugin's on_window_ready restore has applied (built hidden above).
1088
- if restore {
1089
- if let Some(window) = app.get_webview_window("main") {
1090
- let _ = window.show();
1091
- }
1092
- }
1093
- // Per-window plugin attach: fan out `window-created` to the plugin host so plugins whose
1094
- // `attachTo` matches "main" run their `onWindow(ctx)`. No-op when no host is running.
1095
- #[cfg(target_os = "macos")]
1096
- plugin_host::on_window_created("main", "index.html");
1097
- // Re-open browser sub-windows that were open at last quit. MUST run off the main thread —
1098
- // open() marshals to the main thread via with_webview + blocks, so a synchronous call here
1099
- // (setup runs on the main thread) would deadlock (same class as the ⌘⇧N freeze fix).
1100
- #[cfg(target_os = "macos")]
1101
- {
1102
- let app_handle = app.handle().clone();
1103
- tauri::async_runtime::spawn(async move { browser_tab::restore_subwindows(app_handle); });
1104
- }
1105
- // Local-server mode: boot the server child now (the window shows the bundled splash), then a
1106
- // background thread polls its port and navigates the window to it — or, on timeout/spawn
1107
- // failure, writes the reason into the splash. Absent `server` → the bundled dist just loads.
1108
- #[cfg(target_os = "macos")]
1109
- if let Some(sc) = shell().server.clone() {
1110
- match server::spawn(&server::ServerCfg {
1111
- command: &sc.command,
1112
- args: &sc.args,
1113
- cwd: &sc.cwd,
1114
- path: &sc.path,
1115
- url_marker: &sc.url_marker,
1116
- }) {
1117
- Ok(url_rx) => {
1118
- let app_handle = app.handle().clone();
1119
- std::thread::spawn(move || {
1120
- let timeout = std::time::Duration::from_millis(sc.ready_timeout_ms);
1121
- // urlMarker mode → the captured URL (dynamic port); else TCP-poll the fixed port.
1122
- let url = match url_rx {
1123
- Some(rx) => rx.recv_timeout(timeout).ok(),
1124
- None => server::wait_for_port(sc.port, sc.ready_timeout_ms)
1125
- .then(|| format!("{}://localhost:{}", if sc.https { "https" } else { "http" }, sc.port)),
1126
- };
1127
- let Some(win) = app_handle.get_webview_window("main") else { return };
1128
- match url {
1129
- Some(u) => {
1130
- // Flip to loaded + drain any cold deep link together, so a link
1131
- // arriving now either lands in this URL or takes the warm path.
1132
- let cold = {
1133
- let mut nav = SERVER_NAV.lock().unwrap();
1134
- nav.loaded = true;
1135
- nav.pending.take()
1136
- };
1137
- let target = match cold {
1138
- Some(dl) => {
1139
- let sep = if u.contains('?') { '&' } else { '?' };
1140
- format!("{u}{sep}__appwrap_deeplink={}", percent_encode(&dl))
1141
- }
1142
- None => u,
1143
- };
1144
- let _ = win.eval(&format!("location.replace('{target}')"));
1145
- }
1146
- None => {
1147
- let _ = win.eval(&splash_error_js(&format!(
1148
- "Server didn't come up within {}s.",
1149
- sc.ready_timeout_ms / 1000
1150
- )));
1151
- }
1152
- }
1153
- });
1154
- }
1155
- Err(e) => {
1156
- eprintln!("[server] spawn `{}` failed: {e} — server mode inert", sc.command);
1157
- if let Some(win) = app.get_webview_window("main") {
1158
- let _ = win.eval(&splash_error_js(&format!("Failed to start server: {e}")));
1159
- }
1160
- }
1161
- }
1162
- }
1163
- // OS deep links (<urlScheme>://…, registered via the .app's CFBundleURLTypes). Warm →
1164
- // emit `deeplink.open` (same envelope as the internal loopback in appwrap_invoke);
1165
- // pre-handshake → stash the FIRST url, carried back in the handshake response.
1166
- // (oauth does NOT flow through here — ASWebAuthenticationSession captures its own callback.)
1167
- use tauri_plugin_deep_link::DeepLinkExt;
1168
- app.deep_link().on_open_url(move |event| {
1169
- for url in event.urls() {
1170
- let url = url.to_string();
1171
- eprintln!("[bridge] deeplink.open ← {url}");
1172
- // Warm-vs-cold decision + emit is shared with notification taps — see route_deeplink.
1173
- route_deeplink(&url);
1174
- }
1175
- });
1176
- // Cold launch: the URL that LAUNCHED the app (open para://…) can be missed by on_open_url on
1177
- // macOS — retrieve it explicitly so a link that starts the app is honored too. Stashed as a
1178
- // cold deep link (server mode → drained into the boot navigation; see route_deeplink/ServerNav).
1179
- if let Ok(Some(urls)) = app.deep_link().get_current() {
1180
- for url in urls {
1181
- let url = url.to_string();
1182
- eprintln!("[bridge] deeplink.open (launch) ← {url}");
1183
- route_deeplink(&url);
1184
- }
1185
- }
1186
- // Install the UNUserNotificationCenter delegate now (main thread) so a notification tapped
1187
- // right after a cold launch is captured. Idempotent with the schedule-time call.
1188
- #[cfg(target_os = "macos")]
1189
- if let Some(mtm) = objc2::MainThreadMarker::new() {
1190
- notifications_mac::ensure_delegate(mtm);
1191
- }
1192
- Ok(())
1193
- })
1194
- .build(tauri::generate_context!())
1195
- .expect("error while building appwrap desktop shell")
1196
- .run(|app_handle, event| {
1197
- #[cfg(target_os = "macos")]
1198
- match &event {
1199
- // ExitRequested fires while the window still EXISTS (⌘Q / Dock-quit / last-window-close),
1200
- // so persist geometry here — the window-state plugin's own save only fires on window
1201
- // CLOSE events, which those quit paths skip, so position/size wouldn't restore next run.
1202
- tauri::RunEvent::ExitRequested { .. } => {
1203
- // Mark quitting BEFORE windows tear down, so sub-window teardown keeps (not prunes)
1204
- // the restore snapshot — whatever's open at quit re-opens next launch.
1205
- browser_tab::mark_quitting();
1206
- if shell().restore_window_state {
1207
- use tauri_plugin_window_state::{AppHandleExt, StateFlags};
1208
- let _ = app_handle.save_window_state(StateFlags::all());
1209
- }
1210
- server::shutdown();
1211
- }
1212
- // Belt-and-suspenders: also tear down on Exit (idempotent — shutdown() takes the child
1213
- // out of its slot, so this is a no-op after ExitRequested already ran).
1214
- tauri::RunEvent::Exit => server::shutdown(),
1215
- // macOS URL-scheme delivery for BOTH cold launch (app opened BY the link) and warm
1216
- // re-open — the canonical path; `on_open_url`/`get_current` miss the launch URL. Routed
1217
- // through the same handler (server mode → drained into the boot nav or dispatched warm).
1218
- tauri::RunEvent::Opened { urls } => {
1219
- for url in urls {
1220
- let u = url.to_string();
1221
- eprintln!("[bridge] deeplink.open (opened) ← {u}");
1222
- route_deeplink(&u);
1223
- }
1224
- }
1225
- _ => {}
1226
- }
1227
- });
1228
- }