@livx.cc/appwrap 0.42.4 → 0.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +4 -2
  2. package/runtime/app/app.ts +15 -0
  3. package/runtime/app/main-page.ts +11 -2
  4. package/runtime/app/shell/custom-webview.ios.ts +18 -8
  5. package/runtime/app/shell/handlers-background.ts +1 -1
  6. package/runtime/app/shell/web-quirks.ts +63 -0
  7. package/runtime-desktop/autotest/index.html +77 -0
  8. package/runtime-desktop/bridge-shim/build.sh +9 -0
  9. package/runtime-desktop/bridge-shim/content-entry.ts +87 -0
  10. package/runtime-desktop/bridge-shim/shim.ts +317 -0
  11. package/runtime-desktop/src-tauri/Cargo.lock +5461 -0
  12. package/runtime-desktop/src-tauri/Cargo.toml +40 -0
  13. package/runtime-desktop/src-tauri/build.rs +3 -0
  14. package/runtime-desktop/src-tauri/capabilities/default.json +6 -0
  15. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  16. package/runtime-desktop/src-tauri/shell_config.json +10 -0
  17. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +138 -0
  18. package/runtime-desktop/src-tauri/src/bridge_mac.rs +664 -0
  19. package/runtime-desktop/src-tauri/src/main.rs +879 -0
  20. package/runtime-desktop/src-tauri/src/notifications_mac.rs +202 -0
  21. package/runtime-desktop/src-tauri/src/oauth_mac.rs +139 -0
  22. package/runtime-desktop/src-tauri/src/popup_mac.rs +519 -0
  23. package/runtime-desktop/src-tauri/src/push_mac.rs +193 -0
  24. package/runtime-desktop/src-tauri/src/server.rs +156 -0
  25. package/runtime-desktop/src-tauri/src/sidecar.rs +398 -0
  26. package/runtime-desktop/src-tauri/tauri.conf.json +13 -0
  27. package/scripts/stage-assets.mjs +4 -2
  28. package/src/cli.ts +664 -37
  29. package/src/config.ts +76 -1
  30. package/src/desktop.ts +293 -0
  31. package/src/handlers.ts +71 -0
  32. package/src/icon.ts +207 -0
@@ -0,0 +1,879 @@
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
+ #[cfg(target_os = "macos")]
21
+ mod notifications_mac;
22
+ #[cfg(target_os = "macos")]
23
+ mod oauth_mac;
24
+ #[cfg(target_os = "macos")]
25
+ mod popup_mac;
26
+ #[cfg(target_os = "macos")]
27
+ mod push_mac;
28
+ #[cfg(target_os = "macos")]
29
+ mod server;
30
+ #[cfg(target_os = "macos")]
31
+ mod sidecar;
32
+
33
+ static STORAGE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
34
+
35
+ /// App handle, set once in setup() — lets off-main-thread handlers (e.g. `toast.show`) reach
36
+ /// plugin APIs like tauri-plugin-notification, which need a `Manager` to resolve their state.
37
+ static APP: OnceLock<tauri::AppHandle> = OnceLock::new();
38
+
39
+ /// Single in-flight `oauth.authorize` guard (mirrors the mobile shells' one-session model): a second
40
+ /// authorize while one is pending → NATIVE_ERROR. The ASWebAuthenticationSession completion handler
41
+ /// (oauth_mac) resolves/cancels the flow and clears this.
42
+ static OAUTH_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
43
+
44
+ /// Cold-vs-warm deep-link state, under ONE lock: `handshaken` flips on the first `app.handshake`
45
+ /// (after which links are WARM → `deeplink.open` events); `pending` holds a link that arrived
46
+ /// before it (cold launch FROM a link), carried back as `deepLink` in the handshake response
47
+ /// (mobile parity) so the PWA can route before first paint. A single mutex — not an atomic flag
48
+ /// beside a mutex — so the handshake's read-flag+drain and on_open_url's check-then-stash can't
49
+ /// interleave and strand a link that arrives at the exact handshake instant.
50
+ struct DeepLinkState { handshaken: bool, pending: Option<String> }
51
+ static DEEPLINK: Mutex<DeepLinkState> = Mutex::new(DeepLinkState { handshaken: false, pending: None });
52
+
53
+ /// Server-mode (`desktop.server`) deep-link delivery. The page is a plain web app (not a native-kit
54
+ /// consumer), so instead of the native `deeplink.open` handshake we deliver deep links to it directly:
55
+ /// COLD (link arrived before the page loaded) → appended to the navigation URL as `?__appwrap_deeplink=`
56
+ /// so the page reads it on first load; WARM (already loaded) → a `window` CustomEvent `appwrap:deeplink`.
57
+ /// `loaded` flips true when the boot thread navigates to the server URL (drains `pending` atomically).
58
+ struct ServerNav { loaded: bool, pending: Option<String> }
59
+ static SERVER_NAV: Mutex<ServerNav> = Mutex::new(ServerNav { loaded: false, pending: None });
60
+
61
+ /// Percent-encode a string for use as a URL query-param value (RFC3986 unreserved set kept verbatim).
62
+ fn percent_encode(s: &str) -> String {
63
+ let mut out = String::new();
64
+ for b in s.bytes() {
65
+ match b {
66
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
67
+ _ => out.push_str(&format!("%{b:02X}")),
68
+ }
69
+ }
70
+ out
71
+ }
72
+
73
+ /// App identity + window shape, stamped by `appwrap dev|build desktop` from the appwrap config into
74
+ /// `shell_config.json` (embedded at compile time). De-hardcodes what the spike baked in. Mirrors
75
+ /// `DesktopShellConfig` in packages/appwrap-cli/src/desktop.ts.
76
+ #[derive(serde::Deserialize)]
77
+ #[serde(rename_all = "camelCase")]
78
+ struct ShellConfig {
79
+ app_id: String,
80
+ name: String,
81
+ version: String,
82
+ title: String,
83
+ width: f64,
84
+ height: f64,
85
+ url_scheme: String,
86
+ identifier: String,
87
+ /// Persist + restore window size/position across launches (tauri-plugin-window-state). When
88
+ /// false the plugin isn't registered, so the configured width/height always apply.
89
+ #[serde(default = "default_true")]
90
+ restore_window_state: bool,
91
+ /// Append `prompt=select_account` to Google `signInWithPopup` OAuth popup URLs (see popup_mac).
92
+ #[serde(default)]
93
+ google_account_picker: bool,
94
+ /// Launch on login (tauri-plugin-autostart, macOS LaunchAgent). On setup the shell enables the
95
+ /// login item when true and explicitly disables it when false — so flipping the config off
96
+ /// removes the item on the next launch.
97
+ #[serde(default)]
98
+ autostart: bool,
99
+ /// Absolute path to the app's custom-handlers TS file (stamped by the CLI). When non-empty the shell
100
+ /// spawns a `bun <path>` sidecar at setup and routes methods the sidecar advertises to it (see
101
+ /// sidecar.rs). Empty/absent → the feature is fully inert (no sidecar).
102
+ #[serde(default)]
103
+ handlers: String,
104
+ /// Absolute path to the Bun binary to spawn the sidecar with (the CLI stamps its own
105
+ /// `process.execPath`). A GUI-launched .app inherits the launchd PATH (no ~/.bun/bin), so a
106
+ /// bare `bun` lookup would fail there; sidecar.rs falls back to `bun` when this is empty/gone.
107
+ #[serde(default)]
108
+ handlers_runtime: String,
109
+ /// True when the CLI resolved a real signing identity + macOS provisioning profile for this
110
+ /// build — i.e. the .app carries the aps-environment entitlement, so APNs registration can
111
+ /// actually succeed. Gates push.register and the handshake's push capability (adhoc → 'none').
112
+ #[serde(default)]
113
+ push_signed: bool,
114
+ /// Local-server mode: when present the shell boots this command, waits for its port, then
115
+ /// navigates the window to `http://localhost:<port>` (instead of loading the bundled dist), and
116
+ /// SIGTERMs its process group on close. Absent → bundled-PWA load. See server.rs.
117
+ #[serde(default)]
118
+ server: Option<ServerConfig>,
119
+ }
120
+
121
+ /// Resolved local-server config (mirrors `DesktopShellConfig.server` in desktop.ts; `command`/`cwd`/
122
+ /// `path` are absolute, stamped by the CLI).
123
+ #[derive(serde::Deserialize, Clone)]
124
+ #[serde(rename_all = "camelCase")]
125
+ struct ServerConfig {
126
+ command: String,
127
+ #[serde(default)]
128
+ args: Vec<String>,
129
+ #[serde(default)]
130
+ cwd: String,
131
+ /// Non-empty → capture the URL the server prints on the first stdout line containing this marker
132
+ /// (tracks a dynamic port). Empty → use the fixed `port` below.
133
+ #[serde(default)]
134
+ url_marker: String,
135
+ #[serde(default)]
136
+ port: u16,
137
+ /// Load over https (vs http) in fixed-`port` mode. True when the server serves TLS (e.g. vite + mkcert).
138
+ #[serde(default)]
139
+ https: bool,
140
+ /// Reserved: readiness currently uses a TCP-accept probe (sufficient for vite/preview), so the
141
+ /// path isn't hit yet — kept in the wire config for a future HTTP health probe.
142
+ #[serde(default)]
143
+ #[allow(dead_code)]
144
+ health_path: String,
145
+ ready_timeout_ms: u64,
146
+ #[serde(default)]
147
+ path: String,
148
+ }
149
+
150
+ fn default_true() -> bool { true }
151
+
152
+ fn shell() -> &'static ShellConfig {
153
+ static CFG: OnceLock<ShellConfig> = OnceLock::new();
154
+ CFG.get_or_init(|| {
155
+ serde_json::from_str(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/shell_config.json")))
156
+ .expect("invalid shell_config.json")
157
+ })
158
+ }
159
+
160
+ const INIT_JS: &str = r#"
161
+ (() => {
162
+ const invoke = window.__TAURI_INTERNALS__.invoke;
163
+ window.appwrapNative = {
164
+ postMessage: (json) => { invoke('appwrap_invoke', { envelope: json }); },
165
+ };
166
+ })();
167
+ "#;
168
+
169
+ /// Build a JS snippet that swaps the boot splash into an error state with `msg` (server mode). Escapes
170
+ /// the message for a single-quoted JS string literal.
171
+ fn splash_error_js(msg: &str) -> String {
172
+ let esc = msg
173
+ .replace('\\', "\\\\")
174
+ .replace('\'', "\\'")
175
+ .replace(['\n', '\r'], " ");
176
+ format!(
177
+ "(function(){{var s=document.getElementById('spin');if(s)s.remove();\
178
+ var m=document.getElementById('m');if(m)m.textContent='Server didn\\'t start';\
179
+ var e=document.getElementById('e');if(e)e.textContent='{esc}';}})()"
180
+ )
181
+ }
182
+
183
+ type HandlerResult = Result<Value, (&'static str, String)>;
184
+
185
+ /// spawn() without wait() leaks a zombie per call; reap in a detached thread.
186
+ fn spawn_reaped(mut cmd: std::process::Command) -> Result<(), (&'static str, String)> {
187
+ let mut child = cmd.spawn().map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
188
+ std::thread::spawn(move || { let _ = child.wait(); });
189
+ Ok(())
190
+ }
191
+
192
+ /// Show a real OS notification (tauri-plugin-notification). Checks/asks permission first and errors
193
+ /// (not panics) on a missing app handle, denied permission, or delivery failure — the caller decides
194
+ /// how to degrade. On macOS the plugin routes through the app bundle's identity, so Notification
195
+ /// Center attributes it to the shell; unbundled `cargo run` may fail to deliver (no bundle id) → logged.
196
+ fn show_notification(title: &str, body: &str) -> Result<(), String> {
197
+ use tauri::plugin::PermissionState;
198
+ use tauri_plugin_notification::NotificationExt;
199
+ let app = APP.get().ok_or("app handle not ready")?;
200
+ let notifier = app.notification();
201
+ let state = match notifier.permission_state().map_err(|e| e.to_string())? {
202
+ PermissionState::Prompt | PermissionState::PromptWithRationale => {
203
+ notifier.request_permission().map_err(|e| e.to_string())?
204
+ }
205
+ s => s,
206
+ };
207
+ if state != PermissionState::Granted {
208
+ return Err(format!("permission not granted ({state:?})"));
209
+ }
210
+ notifier
211
+ .builder()
212
+ .title(title)
213
+ .body(body)
214
+ .show()
215
+ .map_err(|e| e.to_string())
216
+ }
217
+
218
+ /// Set (Some(n)) or clear (None) the Dock-icon badge on the main window. Resolves the window via the
219
+ /// stashed app handle so the worker-thread handlers can reach it; the tauri call posts to the event
220
+ /// loop, so it's safe off the main thread.
221
+ fn set_badge(count: Option<i64>) -> Result<(), (&'static str, String)> {
222
+ let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
223
+ let window = app
224
+ .get_webview_window("main")
225
+ .ok_or(("NATIVE_ERROR", "no main window".to_string()))?;
226
+ window
227
+ .set_badge_count(count)
228
+ .map_err(|e| ("NATIVE_ERROR", e.to_string()))
229
+ }
230
+
231
+ /// Request user attention on the main window (macOS: bounce the Dock icon). `critical` bounces until
232
+ /// the app gains focus; otherwise a single informational bounce. Like `set_badge`, the underlying
233
+ /// `request_user_attention` posts to the event loop (send_user_message), so it's safe off the main
234
+ /// thread — no `run_on_main_thread` needed.
235
+ fn request_attention(critical: bool) -> Result<(), (&'static str, String)> {
236
+ let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
237
+ let window = app
238
+ .get_webview_window("main")
239
+ .ok_or(("NATIVE_ERROR", "no main window".to_string()))?;
240
+ let kind = if critical {
241
+ tauri::UserAttentionType::Critical
242
+ } else {
243
+ tauri::UserAttentionType::Informational
244
+ };
245
+ window
246
+ .request_user_attention(Some(kind))
247
+ .map_err(|e| ("NATIVE_ERROR", e.to_string()))
248
+ }
249
+
250
+ /// Method-namespace prefixes served by the built-in `handle()` below (kept next to it — add the
251
+ /// prefix here when you add a new built-in family). sidecar::init REJECTS any sidecar claim under
252
+ /// these, so an app handler can never shadow a built-in (e.g. a custom `storage.get`).
253
+ pub const BUILTIN_METHOD_PREFIXES: &[&str] = &[
254
+ "app.", "biometrics.", "browser.", "clipboard.", "deeplink.", "device.", "network.",
255
+ "notifications.", "oauth.", "push.", "storage.", "toast.", "ui.",
256
+ ];
257
+
258
+ fn handle(method: &str, params: &Value) -> HandlerResult {
259
+ match method {
260
+ "app.handshake" => {
261
+ // One critical section: flip to warm AND drain any cold link together (see DEEPLINK).
262
+ let cold_link = {
263
+ let mut dl = DEEPLINK.lock().unwrap();
264
+ dl.handshaken = true;
265
+ dl.pending.take()
266
+ };
267
+ let mut resp = json!({
268
+ "protocol": 1,
269
+ "platform": "desktop",
270
+ "app": {
271
+ "id": shell().app_id,
272
+ "name": shell().name,
273
+ "version": shell().version,
274
+ "loader": "app"
275
+ },
276
+ // Explicit 'none' = veto (kit will NOT web-upgrade it): things a desktop
277
+ // genuinely lacks. Keys OMITTED here degrade to the kit's WebAdapter
278
+ // (share, geo, speech, media, ...) per the hybrid-fallback merge.
279
+ "capabilities": {
280
+ "clipboard": "native",
281
+ "toast": "native",
282
+ "device": "native",
283
+ "storage": "native",
284
+ "app": "native",
285
+ "browser": "native",
286
+ "network": "native",
287
+ "ui": "native",
288
+ "deeplinks": "native",
289
+ "oauth": "native",
290
+ // Touch ID via LocalAuthentication. The kit's WebAdapter stub answers available:
291
+ // false / authenticate→UNSUPPORTED (web-adapter.ts), so OMITTING the key would peg
292
+ // the module to a permanent no-op. We DO have a real native lane, so 'native' is the
293
+ // honest value — and it stays honest whether or not THIS Mac has enrolled hardware
294
+ // (biometrics.available then returns available:false, a valid native response).
295
+ "biometrics": "native",
296
+ // Dock-icon badge is native (notifications.setBadge/clear → NSDockTile); this is the
297
+ // key `kit.app.badgeCapability` reads. `notifications` is 'native' too: schedule/
298
+ // pending/requestPermission are intercepted below and served by notifications_mac
299
+ // (UNUserNotificationCenter — the only lane whose tap callback can route a stamped
300
+ // deepLink; the web Notification API fallback never fires since these never return
301
+ // UNSUPPORTED).
302
+ "notifications": "native",
303
+ "badge": "native",
304
+ "haptics": "none",
305
+ // 'native' ONLY when the CLI stamped push_signed (signed .app with an embedded
306
+ // profile backing aps-environment) — an adhoc build can never get an APNs token.
307
+ "push": if shell().push_signed { "native" } else { "none" },
308
+ "motion": "none"
309
+ }
310
+ });
311
+ // Cold launch FROM a deep link: hand the launching URL back synchronously (mobile parity —
312
+ // see `deepLink` on the kit's Handshake type) so the PWA routes before first paint.
313
+ if let Some(link) = cold_link {
314
+ resp["deepLink"] = json!(link);
315
+ }
316
+ Ok(resp)
317
+ }
318
+ "app.environment" => Ok(json!({
319
+ "platform": "desktop",
320
+ "os": std::env::consts::OS,
321
+ "arch": std::env::consts::ARCH,
322
+ "debug": cfg!(debug_assertions)
323
+ })),
324
+ "app.canOpenUrl" => Ok(json!({ "ok": params["url"].as_str().is_some() })),
325
+ "app.openUrl" | "browser.open" => {
326
+ let url = params["url"].as_str().ok_or(("NATIVE_ERROR", "missing url".to_string()))?;
327
+ if !(url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")) {
328
+ return Err(("DENIED", format!("scheme not allowed: {url}")));
329
+ }
330
+ let mut cmd = std::process::Command::new("open");
331
+ cmd.arg(url);
332
+ spawn_reaped(cmd)?;
333
+ Ok(json!({ "ok": true }))
334
+ }
335
+ // Bounce the Dock icon to request attention (macOS). `critical` (default false) bounces until
336
+ // the app regains focus; otherwise a single bounce. No kit capability key advertises this —
337
+ // apps call it directly via kit.invoke('app.requestAttention'). Routes to request_user_attention
338
+ // which posts to the event loop, so it's thread-safe from this worker thread.
339
+ "app.requestAttention" => {
340
+ let critical = params["critical"].as_bool().unwrap_or(false);
341
+ request_attention(critical)?;
342
+ Ok(json!({ "ok": true }))
343
+ }
344
+ "app.openSettings" => {
345
+ let mut cmd = std::process::Command::new("open");
346
+ cmd.arg("x-apple.systempreferences:");
347
+ spawn_reaped(cmd)?;
348
+ Ok(json!({ "ok": true }))
349
+ }
350
+ "network.status" => Ok(json!({ "online": true, "type": "wifi" })),
351
+ "ui.safeArea" => Ok(json!({ "top": 0, "right": 0, "bottom": 0, "left": 0 })),
352
+ "ui.alert" | "ui.confirm" => {
353
+ let message = params["message"].as_str().unwrap_or("").replace('"', "\\\"");
354
+ let title = params["title"].as_str().unwrap_or(&shell().name).replace('"', "\\\"");
355
+ let buttons = if method == "ui.confirm" { "{\"Cancel\", \"OK\"}" } else { "{\"OK\"}" };
356
+ let script = format!(
357
+ "display dialog \"{message}\" with title \"{title}\" buttons {buttons} default button -1"
358
+ );
359
+ let out = std::process::Command::new("osascript")
360
+ .args(["-e", &script])
361
+ .output()
362
+ .map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
363
+ // osascript exits 1 on Cancel; that's a `false` confirm, not an error.
364
+ let confirmed = out.status.success();
365
+ Ok(json!({ "ok": true, "confirmed": confirmed }))
366
+ }
367
+ "device.info" => Ok(json!({
368
+ "platform": "desktop",
369
+ "os": std::env::consts::OS,
370
+ "arch": std::env::consts::ARCH,
371
+ "model": "mac",
372
+ })),
373
+ "clipboard.copy" => {
374
+ let text = params["text"].as_str().unwrap_or_default().to_string();
375
+ arboard::Clipboard::new()
376
+ .and_then(|mut c| c.set_text(text))
377
+ .map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
378
+ Ok(json!({ "ok": true }))
379
+ }
380
+ "clipboard.read" => {
381
+ let text = arboard::Clipboard::new()
382
+ .and_then(|mut c| c.get_text())
383
+ .unwrap_or_default();
384
+ Ok(json!({ "text": text }))
385
+ }
386
+ "toast.show" => {
387
+ let message = params["message"]
388
+ .as_str()
389
+ .or_else(|| params["text"].as_str())
390
+ .unwrap_or("");
391
+ // Real OS notification via tauri-plugin-notification — proper Notification Center
392
+ // identity from the bundle id (no shell-escaping / osascript hop). Graceful degrade:
393
+ // a denied permission or delivery failure logs and still returns ok (a toast is
394
+ // best-effort UX, never a hard error to the caller).
395
+ if let Err(e) = show_notification(&shell().name, message) {
396
+ eprintln!("[bridge] toast.show notification skipped: {e}");
397
+ }
398
+ Ok(json!({ "ok": true }))
399
+ }
400
+ // Dock-icon badge via the tauri window badge API (macOS NSDockTile). setBadge with count>0
401
+ // shows the number; count 0 (and `clear`) removes it. schedule/pending/requestPermission are
402
+ // NOT implemented here → they return UNSUPPORTED below and the kit degrades them to its
403
+ // WebAdapter (Notification API). `set_badge_count` posts to the event loop (thread-safe from
404
+ // this worker thread). `clear` is badge-only on desktop (no delivered-notification store).
405
+ "notifications.setBadge" => {
406
+ let count = params["count"].as_i64().unwrap_or(0);
407
+ set_badge(if count > 0 { Some(count) } else { None })?;
408
+ Ok(json!({ "ok": true }))
409
+ }
410
+ "notifications.clear" => {
411
+ set_badge(None)?;
412
+ Ok(json!({ "ok": true }))
413
+ }
414
+ m if m.starts_with("storage.") => storage_handle(m, params),
415
+ _ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
416
+ }
417
+ }
418
+
419
+ /// Persisted-store file: ~/Library/Application Support/<identifier>/storage.json (macOS app-data dir).
420
+ fn storage_path() -> std::path::PathBuf {
421
+ let home = std::env::var("HOME").unwrap_or_default();
422
+ std::path::Path::new(&home)
423
+ .join("Library/Application Support")
424
+ .join(&shell().identifier)
425
+ .join("storage.json")
426
+ }
427
+
428
+ fn load_store() -> HashMap<String, String> {
429
+ std::fs::read_to_string(storage_path())
430
+ .ok()
431
+ .and_then(|s| serde_json::from_str(&s).ok())
432
+ .unwrap_or_default()
433
+ }
434
+
435
+ fn save_store(store: &HashMap<String, String>) -> Result<(), String> {
436
+ let path = storage_path();
437
+ if let Some(dir) = path.parent() {
438
+ std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
439
+ }
440
+ let data = serde_json::to_string(store).map_err(|e| e.to_string())?;
441
+ std::fs::write(&path, data).map_err(|e| e.to_string())
442
+ }
443
+
444
+ /// storage.* — persisted to a JSON file in the macOS app-data dir (write-through, in-memory cache).
445
+ /// Note secure.* rides the SAME plaintext file: desktop parity is "persistent", not encrypted (a
446
+ /// Keychain-backed secure lane is out of scope for phase-0).
447
+ fn storage_handle(method: &str, params: &Value) -> HandlerResult {
448
+ let mut guard = STORAGE.lock().unwrap();
449
+ let store = guard.get_or_insert_with(load_store);
450
+ let key = params["key"].as_str().unwrap_or_default();
451
+ let persist = |store: &HashMap<String, String>| -> HandlerResult {
452
+ save_store(store).map_err(|e| ("NATIVE_ERROR", e))?;
453
+ Ok(json!({ "ok": true }))
454
+ };
455
+ match method {
456
+ "storage.get" | "storage.secure.get" => Ok(json!({ "value": store.get(key) })),
457
+ "storage.set" | "storage.secure.set" => {
458
+ let value = params["value"].as_str().unwrap_or_default().to_string();
459
+ store.insert(key.to_string(), value);
460
+ persist(store)
461
+ }
462
+ "storage.remove" | "storage.secure.remove" => {
463
+ store.remove(key);
464
+ persist(store)
465
+ }
466
+ "storage.clear" => {
467
+ store.clear();
468
+ persist(store)
469
+ }
470
+ _ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
471
+ }
472
+ }
473
+
474
+ #[tauri::command]
475
+ fn appwrap_invoke(window: tauri::WebviewWindow, envelope: String) {
476
+ let req: Value = match serde_json::from_str(&envelope) {
477
+ Ok(v) => v,
478
+ Err(_) => return,
479
+ };
480
+ if req["kind"] != "request" {
481
+ return;
482
+ }
483
+ let id = req["id"].as_str().unwrap_or_default().to_string();
484
+ let method = req["method"].as_str().unwrap_or_default().to_string();
485
+ eprintln!("[bridge] {id} {method}");
486
+ let params = req["params"].clone();
487
+ // oauth.authorize replies asynchronously from the ASWebAuthenticationSession completion handler
488
+ // (must start on the main thread; the sheet stays up for minutes) — not from the worker thread.
489
+ if method == "oauth.authorize" {
490
+ let url = params["url"].as_str().unwrap_or_default().to_string();
491
+ let scheme = params["callbackScheme"].as_str().unwrap_or_default().to_string();
492
+ let ephemeral = params["ephemeral"].as_bool().unwrap_or(false);
493
+ if url.is_empty() || scheme.is_empty() {
494
+ deliver_error(&window, &id, "NATIVE_ERROR", "oauth.authorize: empty url or callbackScheme");
495
+ return;
496
+ }
497
+ if OAUTH_IN_FLIGHT.swap(true, Ordering::SeqCst) {
498
+ deliver_error(&window, &id, "NATIVE_ERROR", "oauth.authorize: a flow is already in progress");
499
+ return;
500
+ }
501
+ #[cfg(target_os = "macos")]
502
+ oauth_mac::start(window, id, url, scheme, ephemeral);
503
+ #[cfg(not(target_os = "macos"))]
504
+ {
505
+ let _ = (url, scheme, ephemeral);
506
+ OAUTH_IN_FLIGHT.store(false, Ordering::SeqCst);
507
+ deliver_error(&window, &id, "UNSUPPORTED", "oauth.authorize not implemented on this desktop OS");
508
+ }
509
+ return;
510
+ }
511
+ // push.register/unregister reply asynchronously from the APNs delegate callbacks (main thread) —
512
+ // and are only honest on a signed build (push_signed): adhoc has no aps entitlement, so
513
+ // registerForRemoteNotifications would just fail. Mirrors the mobile shell's envelope.
514
+ if method == "push.register" || method == "push.unregister" {
515
+ if !shell().push_signed {
516
+ deliver_error(&window, &id, "UNSUPPORTED",
517
+ "push needs a signed desktop build (set teamId/APPWRAP_TEAM_ID with an installed macOS provisioning profile)");
518
+ return;
519
+ }
520
+ #[cfg(target_os = "macos")]
521
+ {
522
+ if method == "push.register" { push_mac::register(window, id); }
523
+ else { push_mac::unregister(window, id); }
524
+ }
525
+ #[cfg(not(target_os = "macos"))]
526
+ deliver_error(&window, &id, "UNSUPPORTED", "push not implemented on this desktop OS");
527
+ return;
528
+ }
529
+ // biometrics.* — LocalAuthentication. `available` is a synchronous query; `authenticate` shows the
530
+ // Touch ID sheet and replies asynchronously from the LA completion block (main thread). Both live
531
+ // here (not in handle()) so the sheet starts off-worker-thread and the reply rides the window.
532
+ if method.starts_with("biometrics.") {
533
+ #[cfg(target_os = "macos")]
534
+ {
535
+ match method.as_str() {
536
+ "biometrics.available" => {
537
+ let resp = json!({ "v": 1, "id": id, "kind": "response", "result": biometrics_mac::available() });
538
+ deliver(&window, &resp);
539
+ }
540
+ "biometrics.authenticate" => {
541
+ let reason = params["reason"].as_str().unwrap_or_default().to_string();
542
+ biometrics_mac::authenticate(window, id, reason);
543
+ }
544
+ _ => deliver_error(&window, &id, "UNSUPPORTED", &format!("{method} not implemented on desktop")),
545
+ }
546
+ }
547
+ #[cfg(not(target_os = "macos"))]
548
+ deliver_error(&window, &id, "UNSUPPORTED", "biometrics not implemented on this desktop OS");
549
+ return;
550
+ }
551
+ // notifications.requestPermission / .schedule / .pending reply asynchronously from
552
+ // UNUserNotificationCenter completion blocks (started on the main thread) — so they intercept here,
553
+ // like biometrics/oauth. setBadge/clear are synchronous and fall through to handle() below.
554
+ #[cfg(target_os = "macos")]
555
+ if matches!(method.as_str(), "notifications.requestPermission" | "notifications.schedule" | "notifications.pending") {
556
+ match method.as_str() {
557
+ "notifications.requestPermission" => notifications_mac::request_permission(window, id),
558
+ "notifications.schedule" => notifications_mac::schedule(window, id, params),
559
+ "notifications.pending" => notifications_mac::pending(window, id),
560
+ _ => unreachable!(),
561
+ }
562
+ return;
563
+ }
564
+ // Off the main thread: handlers may block (clipboard, subprocess).
565
+ std::thread::spawn(move || {
566
+ // The app's own url scheme is an INTERNAL deep link (mobile-shell parity):
567
+ // loop it back as a `deeplink.open` event instead of shelling out to `open`.
568
+ let scheme = shell().url_scheme.as_str();
569
+ if !scheme.is_empty()
570
+ && (method == "app.openUrl" || method == "browser.open")
571
+ && params["url"].as_str().is_some_and(|u| u.starts_with(&format!("{scheme}://")))
572
+ {
573
+ let event = json!({ "v": 1, "kind": "event", "event": "deeplink.open",
574
+ "payload": { "url": params["url"] } });
575
+ deliver(&window, &event);
576
+ let resp = json!({ "v": 1, "id": id, "kind": "response", "result": { "ok": true } });
577
+ deliver(&window, &resp);
578
+ return;
579
+ }
580
+ // App-custom methods: forward to the Bun sidecar (desktop.handlers) if it advertised this
581
+ // method. Built-in collisions are ENFORCED away: sidecar::init drops any claim under
582
+ // BUILTIN_METHOD_PREFIXES (below), so a custom `storage.get` can never shadow handle().
583
+ // The sidecar keeps its own (dynamic) error codes — so build the envelope here rather than
584
+ // routing through handle()'s &'static-str-coded HandlerResult.
585
+ #[cfg(target_os = "macos")]
586
+ if sidecar::claims(&method) {
587
+ let resp = match sidecar::request(&method, &params) {
588
+ Ok(result) => json!({ "v": 1, "id": id, "kind": "response", "result": result }),
589
+ Err((code, message)) => json!({
590
+ "v": 1, "id": id, "kind": "response",
591
+ "error": { "code": code, "message": message }
592
+ }),
593
+ };
594
+ deliver(&window, &resp);
595
+ return;
596
+ }
597
+ let resp = match handle(&method, &params) {
598
+ Ok(result) => json!({ "v": 1, "id": id, "kind": "response", "result": result }),
599
+ Err((code, message)) => json!({
600
+ "v": 1, "id": id, "kind": "response",
601
+ "error": { "code": code, "message": message }
602
+ }),
603
+ };
604
+ deliver(&window, &resp);
605
+ });
606
+ }
607
+
608
+ /// Error response envelope (same shape as the inline error json in appwrap_invoke).
609
+ fn deliver_error(window: &tauri::WebviewWindow, id: &str, code: &str, message: &str) {
610
+ let resp = json!({
611
+ "v": 1, "id": id, "kind": "response",
612
+ "error": { "code": code, "message": message }
613
+ });
614
+ deliver(window, &resp);
615
+ }
616
+
617
+ /// Route an incoming deep link — from the OS (`on_open_url`) OR a tapped local notification carrying
618
+ /// one (notifications_mac) — through the SAME warm/cold path: warm (post-handshake) → emit a
619
+ /// `deeplink.open` event; pre-handshake → stash the FIRST url as `pending` (drained into the handshake
620
+ /// response so the PWA routes before first paint). Reaches the main window via the stashed AppHandle,
621
+ /// so it's safe to call from any thread. DRY single source for both entry points.
622
+ pub fn route_deeplink(url: &str) {
623
+ // Server-mode apps get direct web delivery (see ServerNav), not the native-kit handshake path.
624
+ #[cfg(target_os = "macos")]
625
+ if shell().server.is_some() {
626
+ let deliver_now = {
627
+ let mut nav = SERVER_NAV.lock().unwrap();
628
+ if nav.loaded {
629
+ true
630
+ } else {
631
+ nav.pending = Some(url.to_string()); // cold → boot thread appends to the nav URL
632
+ false
633
+ }
634
+ };
635
+ if deliver_now {
636
+ if let Some(window) = APP.get().and_then(|app| app.get_webview_window("main")) {
637
+ let esc = url.replace('\\', "\\\\").replace('\'', "\\'");
638
+ let _ = window.eval(&format!(
639
+ "window.dispatchEvent(new CustomEvent('appwrap:deeplink',{{detail:'{esc}'}}))"
640
+ ));
641
+ }
642
+ }
643
+ return;
644
+ }
645
+ let warm = {
646
+ let mut dl = DEEPLINK.lock().unwrap();
647
+ if !dl.handshaken && dl.pending.is_none() {
648
+ dl.pending = Some(url.to_string());
649
+ }
650
+ dl.handshaken
651
+ };
652
+ if warm {
653
+ if let Some(window) = APP.get().and_then(|app| app.get_webview_window("main")) {
654
+ let event = json!({ "v": 1, "kind": "event", "event": "deeplink.open",
655
+ "payload": { "url": url } });
656
+ deliver(&window, &event);
657
+ }
658
+ }
659
+ }
660
+
661
+ fn deliver(window: &tauri::WebviewWindow, envelope: &Value) {
662
+ let js = format!(
663
+ "window.__appwrapDeliver({})",
664
+ serde_json::to_string(&envelope.to_string()).unwrap()
665
+ );
666
+ let _ = window.eval(&js);
667
+ }
668
+
669
+ fn main() {
670
+ // No `.menu(...)` override: Tauri v2 installs the default macOS menu (App/Edit/Window),
671
+ // whose Edit item carries the Cut/Copy/Paste/Select-All shortcuts the WebView needs — so
672
+ // Cmd+C/V/X/A work in inputs with zero shell code. Suppressing it would break those bindings.
673
+ let mut builder = tauri::Builder::default()
674
+ .plugin(tauri_plugin_deep_link::init())
675
+ .plugin(tauri_plugin_notification::init())
676
+ // Launch-on-login. LaunchAgent (default) writes a per-user LaunchAgent plist — works for the
677
+ // adhoc-signed .app (no Developer ID needed). The setup hook below flips the item on/off to
678
+ // match the config so turning `autostart` off actually removes it.
679
+ .plugin(tauri_plugin_autostart::init(
680
+ tauri_plugin_autostart::MacosLauncher::LaunchAgent,
681
+ None,
682
+ ));
683
+ // Window size/position persistence across launches. The plugin auto-restores on window-ready
684
+ // and saves on close/move/resize; skipping registration (restoreWindowState:false) means the
685
+ // configured width/height always apply. Registered BEFORE the window is built so its
686
+ // on_window_ready restore hook fires for it.
687
+ if shell().restore_window_state {
688
+ builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
689
+ }
690
+ builder
691
+ .invoke_handler(tauri::generate_handler![appwrap_invoke])
692
+ // Popups are plain NSWindows tauri doesn't know about; with the main (only tauri) window
693
+ // closed, tauri exits the app — but tear popups down explicitly so no dangling windows or
694
+ // live child webviews outlive the shell during teardown (or if the exit is ever prevented).
695
+ .on_window_event(|_window, _event| {
696
+ #[cfg(target_os = "macos")]
697
+ if _window.label() == "main"
698
+ && matches!(_event, tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed)
699
+ {
700
+ popup_mac::close_all();
701
+ // Kill the handlers sidecar so the Bun process never outlives the shell.
702
+ sidecar::shutdown();
703
+ // NB: server teardown runs on RunEvent::Exit (below), NOT here — a blocking kill in
704
+ // this close handler would starve tauri-plugin-window-state's own save-on-close.
705
+ }
706
+ })
707
+ .setup(|app| {
708
+ // Stash the app handle so off-main-thread handlers can reach plugin state (notifications).
709
+ let _ = APP.set(app.handle().clone());
710
+ // SPIKE: browser-bridge window-ops backend — inert unless APPWRAP_BRIDGE_JS is set.
711
+ #[cfg(target_os = "macos")]
712
+ bridge_mac::init(app.handle().clone());
713
+ // Per-app custom handlers: spawn the Bun sidecar when configured (absolute path stamped by
714
+ // the CLI). No-op / degrades to inert when empty, on spawn failure, or on a missing `ready`.
715
+ // NOTE: init blocks setup for up to READY_TIMEOUT (5s, bounded) — intentional: sidecar
716
+ // methods must be claimable before the webview's first invoke can race them.
717
+ #[cfg(target_os = "macos")]
718
+ if !shell().handlers.is_empty() {
719
+ sidecar::init(&shell().handlers, &shell().handlers_runtime);
720
+ }
721
+ // Launch-on-login: reconcile the login item to the config every launch, so turning
722
+ // `autostart` off (or on) takes effect on the next run. Best-effort — a failure to
723
+ // enable/disable the LaunchAgent shouldn't abort startup.
724
+ {
725
+ use tauri_plugin_autostart::ManagerExt;
726
+ let al = app.autolaunch();
727
+ let r = if shell().autostart { al.enable() } else { al.disable() };
728
+ if let Err(e) = r {
729
+ eprintln!("[bridge] autostart reconcile ({}) failed: {e}", shell().autostart);
730
+ }
731
+ }
732
+ let restore = shell().restore_window_state;
733
+ let win_builder = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
734
+ .title(shell().title.as_str())
735
+ .inner_size(shell().width, shell().height)
736
+ .initialization_script(INIT_JS);
737
+ // With state restore on, the plugin repositions/resizes AFTER creation — build hidden so
738
+ // the restore lands before first paint (no visible jump), then show below.
739
+ let win_builder = if restore { win_builder.visible(false) } else { win_builder };
740
+ // Safari-normalized UA must be set at BUILD time — the first document snapshots
741
+ // navigator.userAgent before setup() runs (probe: install()-time setCustomUserAgent
742
+ // only applied from the next navigation).
743
+ #[cfg(target_os = "macos")]
744
+ let win_builder = win_builder.user_agent(popup_mac::SAFARI_UA);
745
+ win_builder.build()?;
746
+ // Popup-window support (window.open → real child NSWindow; Firebase signInWithPopup)
747
+ // + Safari UA normalization. with_webview runs its closure on the main thread.
748
+ #[cfg(target_os = "macos")]
749
+ if let Some(window) = app.get_webview_window("main") {
750
+ let picker = shell().google_account_picker;
751
+ window.with_webview(move |w| popup_mac::install(w.inner() as _, picker))?;
752
+ }
753
+ // Reveal after the state plugin's on_window_ready restore has applied (built hidden above).
754
+ if restore {
755
+ if let Some(window) = app.get_webview_window("main") {
756
+ let _ = window.show();
757
+ }
758
+ }
759
+ // Local-server mode: boot the server child now (the window shows the bundled splash), then a
760
+ // background thread polls its port and navigates the window to it — or, on timeout/spawn
761
+ // failure, writes the reason into the splash. Absent `server` → the bundled dist just loads.
762
+ #[cfg(target_os = "macos")]
763
+ if let Some(sc) = shell().server.clone() {
764
+ match server::spawn(&server::ServerCfg {
765
+ command: &sc.command,
766
+ args: &sc.args,
767
+ cwd: &sc.cwd,
768
+ path: &sc.path,
769
+ url_marker: &sc.url_marker,
770
+ }) {
771
+ Ok(url_rx) => {
772
+ let app_handle = app.handle().clone();
773
+ std::thread::spawn(move || {
774
+ let timeout = std::time::Duration::from_millis(sc.ready_timeout_ms);
775
+ // urlMarker mode → the captured URL (dynamic port); else TCP-poll the fixed port.
776
+ let url = match url_rx {
777
+ Some(rx) => rx.recv_timeout(timeout).ok(),
778
+ None => server::wait_for_port(sc.port, sc.ready_timeout_ms)
779
+ .then(|| format!("{}://localhost:{}", if sc.https { "https" } else { "http" }, sc.port)),
780
+ };
781
+ let Some(win) = app_handle.get_webview_window("main") else { return };
782
+ match url {
783
+ Some(u) => {
784
+ // Flip to loaded + drain any cold deep link together, so a link
785
+ // arriving now either lands in this URL or takes the warm path.
786
+ let cold = {
787
+ let mut nav = SERVER_NAV.lock().unwrap();
788
+ nav.loaded = true;
789
+ nav.pending.take()
790
+ };
791
+ let target = match cold {
792
+ Some(dl) => {
793
+ let sep = if u.contains('?') { '&' } else { '?' };
794
+ format!("{u}{sep}__appwrap_deeplink={}", percent_encode(&dl))
795
+ }
796
+ None => u,
797
+ };
798
+ let _ = win.eval(&format!("location.replace('{target}')"));
799
+ }
800
+ None => {
801
+ let _ = win.eval(&splash_error_js(&format!(
802
+ "Server didn't come up within {}s.",
803
+ sc.ready_timeout_ms / 1000
804
+ )));
805
+ }
806
+ }
807
+ });
808
+ }
809
+ Err(e) => {
810
+ eprintln!("[server] spawn `{}` failed: {e} — server mode inert", sc.command);
811
+ if let Some(win) = app.get_webview_window("main") {
812
+ let _ = win.eval(&splash_error_js(&format!("Failed to start server: {e}")));
813
+ }
814
+ }
815
+ }
816
+ }
817
+ // OS deep links (<urlScheme>://…, registered via the .app's CFBundleURLTypes). Warm →
818
+ // emit `deeplink.open` (same envelope as the internal loopback in appwrap_invoke);
819
+ // pre-handshake → stash the FIRST url, carried back in the handshake response.
820
+ // (oauth does NOT flow through here — ASWebAuthenticationSession captures its own callback.)
821
+ use tauri_plugin_deep_link::DeepLinkExt;
822
+ app.deep_link().on_open_url(move |event| {
823
+ for url in event.urls() {
824
+ let url = url.to_string();
825
+ eprintln!("[bridge] deeplink.open ← {url}");
826
+ // Warm-vs-cold decision + emit is shared with notification taps — see route_deeplink.
827
+ route_deeplink(&url);
828
+ }
829
+ });
830
+ // Cold launch: the URL that LAUNCHED the app (open para://…) can be missed by on_open_url on
831
+ // macOS — retrieve it explicitly so a link that starts the app is honored too. Stashed as a
832
+ // cold deep link (server mode → drained into the boot navigation; see route_deeplink/ServerNav).
833
+ if let Ok(Some(urls)) = app.deep_link().get_current() {
834
+ for url in urls {
835
+ let url = url.to_string();
836
+ eprintln!("[bridge] deeplink.open (launch) ← {url}");
837
+ route_deeplink(&url);
838
+ }
839
+ }
840
+ // Install the UNUserNotificationCenter delegate now (main thread) so a notification tapped
841
+ // right after a cold launch is captured. Idempotent with the schedule-time call.
842
+ #[cfg(target_os = "macos")]
843
+ if let Some(mtm) = objc2::MainThreadMarker::new() {
844
+ notifications_mac::ensure_delegate(mtm);
845
+ }
846
+ Ok(())
847
+ })
848
+ .build(tauri::generate_context!())
849
+ .expect("error while building appwrap desktop shell")
850
+ .run(|app_handle, event| {
851
+ #[cfg(target_os = "macos")]
852
+ match &event {
853
+ // ExitRequested fires while the window still EXISTS (⌘Q / Dock-quit / last-window-close),
854
+ // so persist geometry here — the window-state plugin's own save only fires on window
855
+ // CLOSE events, which those quit paths skip, so position/size wouldn't restore next run.
856
+ tauri::RunEvent::ExitRequested { .. } => {
857
+ if shell().restore_window_state {
858
+ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
859
+ let _ = app_handle.save_window_state(StateFlags::all());
860
+ }
861
+ server::shutdown();
862
+ }
863
+ // Belt-and-suspenders: also tear down on Exit (idempotent — shutdown() takes the child
864
+ // out of its slot, so this is a no-op after ExitRequested already ran).
865
+ tauri::RunEvent::Exit => server::shutdown(),
866
+ // macOS URL-scheme delivery for BOTH cold launch (app opened BY the link) and warm
867
+ // re-open — the canonical path; `on_open_url`/`get_current` miss the launch URL. Routed
868
+ // through the same handler (server mode → drained into the boot nav or dispatched warm).
869
+ tauri::RunEvent::Opened { urls } => {
870
+ for url in urls {
871
+ let u = url.to_string();
872
+ eprintln!("[bridge] deeplink.open (opened) ← {u}");
873
+ route_deeplink(&u);
874
+ }
875
+ }
876
+ _ => {}
877
+ }
878
+ });
879
+ }