@livx.cc/appwrap 0.43.0 → 0.46.2

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.
@@ -16,6 +16,9 @@ use tauri::Manager;
16
16
  #[cfg(target_os = "macos")]
17
17
  mod biometrics_mac;
18
18
  #[cfg(target_os = "macos")]
19
+ mod bridge_mac;
20
+ mod browser_tab;
21
+ #[cfg(target_os = "macos")]
19
22
  mod notifications_mac;
20
23
  #[cfg(target_os = "macos")]
21
24
  mod oauth_mac;
@@ -24,6 +27,8 @@ mod popup_mac;
24
27
  #[cfg(target_os = "macos")]
25
28
  mod push_mac;
26
29
  #[cfg(target_os = "macos")]
30
+ mod server;
31
+ #[cfg(target_os = "macos")]
27
32
  mod sidecar;
28
33
 
29
34
  static STORAGE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
@@ -46,6 +51,26 @@ static OAUTH_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
46
51
  struct DeepLinkState { handshaken: bool, pending: Option<String> }
47
52
  static DEEPLINK: Mutex<DeepLinkState> = Mutex::new(DeepLinkState { handshaken: false, pending: None });
48
53
 
54
+ /// Server-mode (`desktop.server`) deep-link delivery. The page is a plain web app (not a native-kit
55
+ /// consumer), so instead of the native `deeplink.open` handshake we deliver deep links to it directly:
56
+ /// COLD (link arrived before the page loaded) → appended to the navigation URL as `?__appwrap_deeplink=`
57
+ /// so the page reads it on first load; WARM (already loaded) → a `window` CustomEvent `appwrap:deeplink`.
58
+ /// `loaded` flips true when the boot thread navigates to the server URL (drains `pending` atomically).
59
+ struct ServerNav { loaded: bool, pending: Option<String> }
60
+ static SERVER_NAV: Mutex<ServerNav> = Mutex::new(ServerNav { loaded: false, pending: None });
61
+
62
+ /// Percent-encode a string for use as a URL query-param value (RFC3986 unreserved set kept verbatim).
63
+ fn percent_encode(s: &str) -> String {
64
+ let mut out = String::new();
65
+ for b in s.bytes() {
66
+ match b {
67
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
68
+ _ => out.push_str(&format!("%{b:02X}")),
69
+ }
70
+ }
71
+ out
72
+ }
73
+
49
74
  /// App identity + window shape, stamped by `appwrap dev|build desktop` from the appwrap config into
50
75
  /// `shell_config.json` (embedded at compile time). De-hardcodes what the spike baked in. Mirrors
51
76
  /// `DesktopShellConfig` in packages/appwrap-cli/src/desktop.ts.
@@ -87,6 +112,40 @@ struct ShellConfig {
87
112
  /// actually succeed. Gates push.register and the handshake's push capability (adhoc → 'none').
88
113
  #[serde(default)]
89
114
  push_signed: bool,
115
+ /// Local-server mode: when present the shell boots this command, waits for its port, then
116
+ /// navigates the window to `http://localhost:<port>` (instead of loading the bundled dist), and
117
+ /// SIGTERMs its process group on close. Absent → bundled-PWA load. See server.rs.
118
+ #[serde(default)]
119
+ server: Option<ServerConfig>,
120
+ }
121
+
122
+ /// Resolved local-server config (mirrors `DesktopShellConfig.server` in desktop.ts; `command`/`cwd`/
123
+ /// `path` are absolute, stamped by the CLI).
124
+ #[derive(serde::Deserialize, Clone)]
125
+ #[serde(rename_all = "camelCase")]
126
+ struct ServerConfig {
127
+ command: String,
128
+ #[serde(default)]
129
+ args: Vec<String>,
130
+ #[serde(default)]
131
+ cwd: String,
132
+ /// Non-empty → capture the URL the server prints on the first stdout line containing this marker
133
+ /// (tracks a dynamic port). Empty → use the fixed `port` below.
134
+ #[serde(default)]
135
+ url_marker: String,
136
+ #[serde(default)]
137
+ port: u16,
138
+ /// Load over https (vs http) in fixed-`port` mode. True when the server serves TLS (e.g. vite + mkcert).
139
+ #[serde(default)]
140
+ https: bool,
141
+ /// Reserved: readiness currently uses a TCP-accept probe (sufficient for vite/preview), so the
142
+ /// path isn't hit yet — kept in the wire config for a future HTTP health probe.
143
+ #[serde(default)]
144
+ #[allow(dead_code)]
145
+ health_path: String,
146
+ ready_timeout_ms: u64,
147
+ #[serde(default)]
148
+ path: String,
90
149
  }
91
150
 
92
151
  fn default_true() -> bool { true }
@@ -108,6 +167,20 @@ const INIT_JS: &str = r#"
108
167
  })();
109
168
  "#;
110
169
 
170
+ /// Build a JS snippet that swaps the boot splash into an error state with `msg` (server mode). Escapes
171
+ /// the message for a single-quoted JS string literal.
172
+ fn splash_error_js(msg: &str) -> String {
173
+ let esc = msg
174
+ .replace('\\', "\\\\")
175
+ .replace('\'', "\\'")
176
+ .replace(['\n', '\r'], " ");
177
+ format!(
178
+ "(function(){{var s=document.getElementById('spin');if(s)s.remove();\
179
+ var m=document.getElementById('m');if(m)m.textContent='Server didn\\'t start';\
180
+ var e=document.getElementById('e');if(e)e.textContent='{esc}';}})()"
181
+ )
182
+ }
183
+
111
184
  type HandlerResult = Result<Value, (&'static str, String)>;
112
185
 
113
186
  /// spawn() without wait() leaks a zombie per call; reap in a detached thread.
@@ -548,6 +621,28 @@ fn deliver_error(window: &tauri::WebviewWindow, id: &str, code: &str, message: &
548
621
  /// response so the PWA routes before first paint). Reaches the main window via the stashed AppHandle,
549
622
  /// so it's safe to call from any thread. DRY single source for both entry points.
550
623
  pub fn route_deeplink(url: &str) {
624
+ // Server-mode apps get direct web delivery (see ServerNav), not the native-kit handshake path.
625
+ #[cfg(target_os = "macos")]
626
+ if shell().server.is_some() {
627
+ let deliver_now = {
628
+ let mut nav = SERVER_NAV.lock().unwrap();
629
+ if nav.loaded {
630
+ true
631
+ } else {
632
+ nav.pending = Some(url.to_string()); // cold → boot thread appends to the nav URL
633
+ false
634
+ }
635
+ };
636
+ if deliver_now {
637
+ if let Some(window) = APP.get().and_then(|app| app.get_webview_window("main")) {
638
+ let esc = url.replace('\\', "\\\\").replace('\'', "\\'");
639
+ let _ = window.eval(&format!(
640
+ "window.dispatchEvent(new CustomEvent('appwrap:deeplink',{{detail:'{esc}'}}))"
641
+ ));
642
+ }
643
+ }
644
+ return;
645
+ }
551
646
  let warm = {
552
647
  let mut dl = DEEPLINK.lock().unwrap();
553
648
  if !dl.handshaken && dl.pending.is_none() {
@@ -594,7 +689,13 @@ fn main() {
594
689
  builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
595
690
  }
596
691
  builder
597
- .invoke_handler(tauri::generate_handler![appwrap_invoke])
692
+ .invoke_handler(tauri::generate_handler![
693
+ appwrap_invoke,
694
+ browser_tab::browser_tab_create,
695
+ browser_tab::browser_tab_set_mount,
696
+ browser_tab::browser_tab_navigate,
697
+ browser_tab::browser_tab_close,
698
+ ])
598
699
  // Popups are plain NSWindows tauri doesn't know about; with the main (only tauri) window
599
700
  // closed, tauri exits the app — but tear popups down explicitly so no dangling windows or
600
701
  // live child webviews outlive the shell during teardown (or if the exit is ever prevented).
@@ -606,11 +707,16 @@ fn main() {
606
707
  popup_mac::close_all();
607
708
  // Kill the handlers sidecar so the Bun process never outlives the shell.
608
709
  sidecar::shutdown();
710
+ // NB: server teardown runs on RunEvent::Exit (below), NOT here — a blocking kill in
711
+ // this close handler would starve tauri-plugin-window-state's own save-on-close.
609
712
  }
610
713
  })
611
714
  .setup(|app| {
612
715
  // Stash the app handle so off-main-thread handlers can reach plugin state (notifications).
613
716
  let _ = APP.set(app.handle().clone());
717
+ // SPIKE: browser-bridge window-ops backend — inert unless APPWRAP_BRIDGE_JS is set.
718
+ #[cfg(target_os = "macos")]
719
+ bridge_mac::init(app.handle().clone());
614
720
  // Per-app custom handlers: spawn the Bun sidecar when configured (absolute path stamped by
615
721
  // the CLI). No-op / degrades to inert when empty, on spawn failure, or on a missing `ready`.
616
722
  // NOTE: init blocks setup for up to READY_TIMEOUT (5s, bounded) — intentional: sidecar
@@ -657,6 +763,64 @@ fn main() {
657
763
  let _ = window.show();
658
764
  }
659
765
  }
766
+ // Local-server mode: boot the server child now (the window shows the bundled splash), then a
767
+ // background thread polls its port and navigates the window to it — or, on timeout/spawn
768
+ // failure, writes the reason into the splash. Absent `server` → the bundled dist just loads.
769
+ #[cfg(target_os = "macos")]
770
+ if let Some(sc) = shell().server.clone() {
771
+ match server::spawn(&server::ServerCfg {
772
+ command: &sc.command,
773
+ args: &sc.args,
774
+ cwd: &sc.cwd,
775
+ path: &sc.path,
776
+ url_marker: &sc.url_marker,
777
+ }) {
778
+ Ok(url_rx) => {
779
+ let app_handle = app.handle().clone();
780
+ std::thread::spawn(move || {
781
+ let timeout = std::time::Duration::from_millis(sc.ready_timeout_ms);
782
+ // urlMarker mode → the captured URL (dynamic port); else TCP-poll the fixed port.
783
+ let url = match url_rx {
784
+ Some(rx) => rx.recv_timeout(timeout).ok(),
785
+ None => server::wait_for_port(sc.port, sc.ready_timeout_ms)
786
+ .then(|| format!("{}://localhost:{}", if sc.https { "https" } else { "http" }, sc.port)),
787
+ };
788
+ let Some(win) = app_handle.get_webview_window("main") else { return };
789
+ match url {
790
+ Some(u) => {
791
+ // Flip to loaded + drain any cold deep link together, so a link
792
+ // arriving now either lands in this URL or takes the warm path.
793
+ let cold = {
794
+ let mut nav = SERVER_NAV.lock().unwrap();
795
+ nav.loaded = true;
796
+ nav.pending.take()
797
+ };
798
+ let target = match cold {
799
+ Some(dl) => {
800
+ let sep = if u.contains('?') { '&' } else { '?' };
801
+ format!("{u}{sep}__appwrap_deeplink={}", percent_encode(&dl))
802
+ }
803
+ None => u,
804
+ };
805
+ let _ = win.eval(&format!("location.replace('{target}')"));
806
+ }
807
+ None => {
808
+ let _ = win.eval(&splash_error_js(&format!(
809
+ "Server didn't come up within {}s.",
810
+ sc.ready_timeout_ms / 1000
811
+ )));
812
+ }
813
+ }
814
+ });
815
+ }
816
+ Err(e) => {
817
+ eprintln!("[server] spawn `{}` failed: {e} — server mode inert", sc.command);
818
+ if let Some(win) = app.get_webview_window("main") {
819
+ let _ = win.eval(&splash_error_js(&format!("Failed to start server: {e}")));
820
+ }
821
+ }
822
+ }
823
+ }
660
824
  // OS deep links (<urlScheme>://…, registered via the .app's CFBundleURLTypes). Warm →
661
825
  // emit `deeplink.open` (same envelope as the internal loopback in appwrap_invoke);
662
826
  // pre-handshake → stash the FIRST url, carried back in the handshake response.
@@ -670,6 +834,16 @@ fn main() {
670
834
  route_deeplink(&url);
671
835
  }
672
836
  });
837
+ // Cold launch: the URL that LAUNCHED the app (open para://…) can be missed by on_open_url on
838
+ // macOS — retrieve it explicitly so a link that starts the app is honored too. Stashed as a
839
+ // cold deep link (server mode → drained into the boot navigation; see route_deeplink/ServerNav).
840
+ if let Ok(Some(urls)) = app.deep_link().get_current() {
841
+ for url in urls {
842
+ let url = url.to_string();
843
+ eprintln!("[bridge] deeplink.open (launch) ← {url}");
844
+ route_deeplink(&url);
845
+ }
846
+ }
673
847
  // Install the UNUserNotificationCenter delegate now (main thread) so a notification tapped
674
848
  // right after a cold launch is captured. Idempotent with the schedule-time call.
675
849
  #[cfg(target_os = "macos")]
@@ -678,6 +852,35 @@ fn main() {
678
852
  }
679
853
  Ok(())
680
854
  })
681
- .run(tauri::generate_context!())
682
- .expect("error while running appwrap desktop shell");
855
+ .build(tauri::generate_context!())
856
+ .expect("error while building appwrap desktop shell")
857
+ .run(|app_handle, event| {
858
+ #[cfg(target_os = "macos")]
859
+ match &event {
860
+ // ExitRequested fires while the window still EXISTS (⌘Q / Dock-quit / last-window-close),
861
+ // so persist geometry here — the window-state plugin's own save only fires on window
862
+ // CLOSE events, which those quit paths skip, so position/size wouldn't restore next run.
863
+ tauri::RunEvent::ExitRequested { .. } => {
864
+ if shell().restore_window_state {
865
+ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
866
+ let _ = app_handle.save_window_state(StateFlags::all());
867
+ }
868
+ server::shutdown();
869
+ }
870
+ // Belt-and-suspenders: also tear down on Exit (idempotent — shutdown() takes the child
871
+ // out of its slot, so this is a no-op after ExitRequested already ran).
872
+ tauri::RunEvent::Exit => server::shutdown(),
873
+ // macOS URL-scheme delivery for BOTH cold launch (app opened BY the link) and warm
874
+ // re-open — the canonical path; `on_open_url`/`get_current` miss the launch URL. Routed
875
+ // through the same handler (server mode → drained into the boot nav or dispatched warm).
876
+ tauri::RunEvent::Opened { urls } => {
877
+ for url in urls {
878
+ let u = url.to_string();
879
+ eprintln!("[bridge] deeplink.open (opened) ← {u}");
880
+ route_deeplink(&u);
881
+ }
882
+ }
883
+ _ => {}
884
+ }
885
+ });
683
886
  }