@livx.cc/appwrap 0.43.0 → 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.
@@ -16,6 +16,8 @@ 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
+ #[cfg(target_os = "macos")]
19
21
  mod notifications_mac;
20
22
  #[cfg(target_os = "macos")]
21
23
  mod oauth_mac;
@@ -24,6 +26,8 @@ mod popup_mac;
24
26
  #[cfg(target_os = "macos")]
25
27
  mod push_mac;
26
28
  #[cfg(target_os = "macos")]
29
+ mod server;
30
+ #[cfg(target_os = "macos")]
27
31
  mod sidecar;
28
32
 
29
33
  static STORAGE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
@@ -46,6 +50,26 @@ static OAUTH_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
46
50
  struct DeepLinkState { handshaken: bool, pending: Option<String> }
47
51
  static DEEPLINK: Mutex<DeepLinkState> = Mutex::new(DeepLinkState { handshaken: false, pending: None });
48
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
+
49
73
  /// App identity + window shape, stamped by `appwrap dev|build desktop` from the appwrap config into
50
74
  /// `shell_config.json` (embedded at compile time). De-hardcodes what the spike baked in. Mirrors
51
75
  /// `DesktopShellConfig` in packages/appwrap-cli/src/desktop.ts.
@@ -87,6 +111,40 @@ struct ShellConfig {
87
111
  /// actually succeed. Gates push.register and the handshake's push capability (adhoc → 'none').
88
112
  #[serde(default)]
89
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,
90
148
  }
91
149
 
92
150
  fn default_true() -> bool { true }
@@ -108,6 +166,20 @@ const INIT_JS: &str = r#"
108
166
  })();
109
167
  "#;
110
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
+
111
183
  type HandlerResult = Result<Value, (&'static str, String)>;
112
184
 
113
185
  /// spawn() without wait() leaks a zombie per call; reap in a detached thread.
@@ -548,6 +620,28 @@ fn deliver_error(window: &tauri::WebviewWindow, id: &str, code: &str, message: &
548
620
  /// response so the PWA routes before first paint). Reaches the main window via the stashed AppHandle,
549
621
  /// so it's safe to call from any thread. DRY single source for both entry points.
550
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
+ }
551
645
  let warm = {
552
646
  let mut dl = DEEPLINK.lock().unwrap();
553
647
  if !dl.handshaken && dl.pending.is_none() {
@@ -606,11 +700,16 @@ fn main() {
606
700
  popup_mac::close_all();
607
701
  // Kill the handlers sidecar so the Bun process never outlives the shell.
608
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.
609
705
  }
610
706
  })
611
707
  .setup(|app| {
612
708
  // Stash the app handle so off-main-thread handlers can reach plugin state (notifications).
613
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());
614
713
  // Per-app custom handlers: spawn the Bun sidecar when configured (absolute path stamped by
615
714
  // the CLI). No-op / degrades to inert when empty, on spawn failure, or on a missing `ready`.
616
715
  // NOTE: init blocks setup for up to READY_TIMEOUT (5s, bounded) — intentional: sidecar
@@ -657,6 +756,64 @@ fn main() {
657
756
  let _ = window.show();
658
757
  }
659
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
+ }
660
817
  // OS deep links (<urlScheme>://…, registered via the .app's CFBundleURLTypes). Warm →
661
818
  // emit `deeplink.open` (same envelope as the internal loopback in appwrap_invoke);
662
819
  // pre-handshake → stash the FIRST url, carried back in the handshake response.
@@ -670,6 +827,16 @@ fn main() {
670
827
  route_deeplink(&url);
671
828
  }
672
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
+ }
673
840
  // Install the UNUserNotificationCenter delegate now (main thread) so a notification tapped
674
841
  // right after a cold launch is captured. Idempotent with the schedule-time call.
675
842
  #[cfg(target_os = "macos")]
@@ -678,6 +845,35 @@ fn main() {
678
845
  }
679
846
  Ok(())
680
847
  })
681
- .run(tauri::generate_context!())
682
- .expect("error while running appwrap desktop shell");
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
+ });
683
879
  }
@@ -24,8 +24,9 @@ use objc2::{define_class, msg_send, DefinedClass, MainThreadMarker, MainThreadOn
24
24
  use objc2_app_kit::{NSBackingStoreType, NSWindow, NSWindowDelegate, NSWindowStyleMask};
25
25
  use objc2_foundation::{NSNotification, NSPoint, NSRect, NSSize, NSString, NSURL, NSURLRequest};
26
26
  use objc2_web_kit::{
27
- WKNavigationAction, WKNavigationActionPolicy, WKNavigationDelegate, WKUIDelegate, WKWebView,
28
- WKWebViewConfiguration, WKWindowFeatures,
27
+ WKFrameInfo, WKMediaCaptureType, WKNavigationAction, WKNavigationActionPolicy, WKNavigationDelegate,
28
+ WKPermissionDecision, WKSecurityOrigin, WKUIDelegate, WKWebView, WKWebViewConfiguration,
29
+ WKWindowFeatures,
29
30
  };
30
31
 
31
32
  /// When true, Google `signInWithPopup` OAuth popups get `prompt=select_account` appended so a
@@ -111,6 +112,11 @@ struct Popup {
111
112
  /// param) would re-match and re-trigger a cancel+reload loop. Tied to the popup so it's dropped
112
113
  /// with the window.
113
114
  rewritten: AtomicBool,
115
+ /// The Delegate that created this popup (the opener's UI delegate — the global INSTALLED one for
116
+ /// the main shell window, or a per-profile-window delegate). It's the KVO `title` observer we
117
+ /// registered on `webview`, so teardown MUST remove the observer using THIS same object (not a
118
+ /// global) or KVO throws / leaks. Held strong so it outlives the observed webview.
119
+ observer: Retained<Delegate>,
114
120
  }
115
121
  unsafe impl Send for Popup {}
116
122
  static POPUPS: Mutex<Vec<Popup>> = Mutex::new(Vec::new());
@@ -143,10 +149,11 @@ fn cleanup_popup(pred: &dyn Fn(&Popup) -> bool, close_window: bool) {
143
149
  (p, descendants_of(&pairs, key))
144
150
  };
145
151
  // Stop observing `title` BEFORE releasing the webview (KVO throws if an observed object
146
- // deallocates with observers attached). Registered unconditionally in create_web_view.
147
- if let Some(observer) = installed_delegate() {
152
+ // deallocates with observers attached). Registered in create_web_view with p.observer as the
153
+ // observer — remove with the SAME object (per-window delegates make a global lookup wrong).
154
+ {
148
155
  let key = NSString::from_str("title");
149
- let _: () = unsafe { msg_send![&*p.webview, removeObserver: &*observer, forKeyPath: &*key] };
156
+ let _: () = unsafe { msg_send![&*p.webview, removeObserver: &*p.observer, forKeyPath: &*key] };
150
157
  }
151
158
  if close_window {
152
159
  p.window.close();
@@ -192,11 +199,6 @@ pub fn close_all() {
192
199
  }
193
200
  }
194
201
 
195
- /// Strong ref to our installed Delegate (the KVO observer), if install() ran.
196
- fn installed_delegate() -> Option<Retained<Delegate>> {
197
- INSTALLED.lock().unwrap().0.as_ref().map(|(d, _)| d.clone())
198
- }
199
-
200
202
  /// Strong refs to our delegate + wry's original (uiDelegate is weak on WKWebView).
201
203
  struct Installed(Option<(Retained<Delegate>, Retained<AnyObject>)>);
202
204
  unsafe impl Send for Installed {}
@@ -231,6 +233,20 @@ define_class!(
231
233
  .map(|s| s.to_string())
232
234
  .unwrap_or_default();
233
235
 
236
+ // Verification #4 proof: the SSO popup inherits the opener's data store (its profile's
237
+ // cookie jar). WebKit hands us a configuration derived from the opener, so its
238
+ // websiteDataStore is the SAME object as the opener's — log the pointer identity.
239
+ unsafe {
240
+ let opener_store = opener.configuration().websiteDataStore();
241
+ let child_store = configuration.websiteDataStore();
242
+ eprintln!(
243
+ "[popup] shared-store check: opener={:p} child={:p} shared={}",
244
+ Retained::as_ptr(&opener_store),
245
+ Retained::as_ptr(&child_store),
246
+ Retained::as_ptr(&opener_store) == Retained::as_ptr(&child_store),
247
+ );
248
+ }
249
+
234
250
  let width = unsafe { features.width() }.map(|n| n.doubleValue()).unwrap_or(720.0).max(200.0);
235
251
  let height = unsafe { features.height() }.map(|n| n.doubleValue()).unwrap_or(640.0).max(200.0);
236
252
  let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(width, height));
@@ -305,6 +321,7 @@ define_class!(
305
321
  webview: child.clone(),
306
322
  parent: opener as *const WKWebView as usize,
307
323
  rewritten: AtomicBool::new(false),
324
+ observer: self.retain(),
308
325
  });
309
326
  // Returning the webview makes WebKit load the navigation action's request itself.
310
327
  Some(child)
@@ -317,6 +334,24 @@ define_class!(
317
334
  // fires but finds no entry → no-op).
318
335
  cleanup_popup(&|p: &Popup| &*p.webview == webview, true);
319
336
  }
337
+
338
+ // getUserMedia (mic/camera — e.g. voice mode): without this WKUIDelegate method the WebView
339
+ // DENIES capture by default, so `navigator.mediaDevices.getUserMedia` rejects. We GRANT it —
340
+ // the page is our own trusted (local/first-party) content. macOS still gates the actual device
341
+ // behind TCC, which needs the app's Info.plist usage string (NSMicrophoneUsageDescription, added
342
+ // when the `media` module is declared) + the user's one-time consent; this only lifts the
343
+ // WebKit-layer denial so that TCC prompt can appear at all.
344
+ #[unsafe(method(webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:))]
345
+ fn request_media_capture(
346
+ &self,
347
+ _web_view: &WKWebView,
348
+ _origin: &WKSecurityOrigin,
349
+ _frame: &WKFrameInfo,
350
+ _capture_type: WKMediaCaptureType,
351
+ decision_handler: &block2::DynBlock<dyn Fn(WKPermissionDecision)>,
352
+ ) {
353
+ decision_handler.call((WKPermissionDecision::Grant,));
354
+ }
320
355
  }
321
356
 
322
357
  // Child-popup navigation policy — only consulted when GOOGLE_ACCOUNT_PICKER wired the child's
@@ -464,3 +499,21 @@ pub fn install(webview_ptr: *mut std::ffi::c_void, google_account_picker: bool)
464
499
  }),
465
500
  ));
466
501
  }
502
+
503
+ /// Install a DEDICATED popup delegate on a manually-created profile window's WKWebView, and return
504
+ /// it so the caller retains it for that window's lifetime (uiDelegate is a WEAK property). Unlike
505
+ /// install(), this does NOT touch the global INSTALLED — every concurrent profile window keeps its
506
+ /// own delegate, so SSO popups work in all of them simultaneously (not last-writer-wins).
507
+ ///
508
+ /// There is no wry delegate to forward to (the webview was built by us, not wry), so file-upload /
509
+ /// media panels aren't forwarded — acceptable (trusted OS input is out of scope). The child popup is
510
+ /// alloc'd with the PARENT's passed WKWebViewConfiguration (create_web_view), which carries the
511
+ /// parent's websiteDataStore — so the popup shares the profile's cookie jar (verification #4).
512
+ pub fn install_for_profile(webview_ptr: *mut std::ffi::c_void, mtm: MainThreadMarker) -> Retained<AnyObject> {
513
+ let webview: &WKWebView = unsafe { &*(webview_ptr as *const WKWebView) };
514
+ let delegate = Delegate::alloc(mtm).set_ivars(DelegateIvars { wry_delegate: None });
515
+ let delegate: Retained<Delegate> = unsafe { msg_send![super(delegate), init] };
516
+ unsafe { webview.setUIDelegate(Some(ProtocolObject::from_ref(&*delegate))) };
517
+ // Opaque to the caller (Delegate is private) — retained only for the window's lifetime.
518
+ unsafe { Retained::cast_unchecked::<AnyObject>(delegate) }
519
+ }
@@ -0,0 +1,156 @@
1
+ //! `desktop.server` mode: boot a local server command, wait for its port, then the shell navigates the
2
+ //! window to it (see main.rs setup). This is the desktop analogue of iOS/Android `loader:'server'`, but
3
+ //! the URL is served by a child process THIS shell owns — so we also tear it down on window-close.
4
+ //!
5
+ //! Teardown correctness: the server (e.g. `para-chat`) itself spawns children (a backend + a vite
6
+ //! preview). A plain SIGKILL of the direct child would orphan those grandchildren. So we `setsid()` the
7
+ //! child into its OWN process group at spawn, then SIGTERM the WHOLE group on shutdown — every
8
+ //! descendant gets the signal and the server's own exit-guard can reap cleanly.
9
+
10
+ use std::io::{BufRead, BufReader};
11
+ use std::net::TcpStream;
12
+ use std::os::unix::process::CommandExt;
13
+ use std::process::{Child, Command, Stdio};
14
+ use std::sync::mpsc::{self, Receiver};
15
+ use std::sync::{Mutex, OnceLock};
16
+ use std::time::{Duration, Instant};
17
+
18
+ /// The booted child + its process-group id (== pid after setsid). Recorded for teardown.
19
+ struct Running {
20
+ child: Child,
21
+ pgid: i32,
22
+ }
23
+
24
+ static RUNNING: OnceLock<Mutex<Option<Running>>> = OnceLock::new();
25
+
26
+ fn slot() -> &'static Mutex<Option<Running>> {
27
+ RUNNING.get_or_init(|| Mutex::new(None))
28
+ }
29
+
30
+ /// Resolved server config (mirrors `ServerConfig` in main.rs).
31
+ pub struct ServerCfg<'a> {
32
+ pub command: &'a str,
33
+ pub args: &'a [String],
34
+ pub cwd: &'a str,
35
+ pub path: &'a str,
36
+ /// Non-empty → capture the printed URL from stdout (see `spawn`). Empty → caller uses wait_for_port.
37
+ pub url_marker: &'a str,
38
+ }
39
+
40
+ /// Extract the first `http://…`/`https://…` token from a line (up to the next whitespace). Pure.
41
+ fn extract_url(line: &str) -> Option<String> {
42
+ let start = line.find("http://").or_else(|| line.find("https://"))?;
43
+ let rest = &line[start..];
44
+ let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
45
+ Some(rest[..end].to_string())
46
+ }
47
+
48
+ /// Spawn the server command in its own process group (setsid), with the CLI-stamped PATH (a
49
+ /// GUI-launched .app has a minimal launchd PATH). Records the child for teardown.
50
+ ///
51
+ /// When `url_marker` is non-empty, stdout is piped and a reader thread tees each line to the shell's
52
+ /// stderr AND, on the first line containing the marker, extracts the printed URL and sends it back —
53
+ /// so the shell loads whatever port the server auto-picked (no hardcoded port). Returns that receiver.
54
+ /// When `url_marker` is empty, stdout is inherited and `None` is returned (caller uses `wait_for_port`).
55
+ pub fn spawn(cfg: &ServerCfg) -> std::io::Result<Option<Receiver<String>>> {
56
+ let capture = !cfg.url_marker.is_empty();
57
+ let mut cmd = Command::new(cfg.command);
58
+ cmd.args(cfg.args);
59
+ if !cfg.cwd.is_empty() {
60
+ cmd.current_dir(cfg.cwd);
61
+ }
62
+ if !cfg.path.is_empty() {
63
+ cmd.env("PATH", cfg.path);
64
+ }
65
+ cmd.stdin(Stdio::null()).stderr(Stdio::inherit());
66
+ cmd.stdout(if capture { Stdio::piped() } else { Stdio::inherit() });
67
+ // New session/process group so we can signal every descendant at teardown. setsid() also makes the
68
+ // child the group leader → its pgid equals its pid.
69
+ unsafe {
70
+ cmd.pre_exec(|| {
71
+ if libc::setsid() == -1 {
72
+ return Err(std::io::Error::last_os_error());
73
+ }
74
+ Ok(())
75
+ });
76
+ }
77
+ let mut child = cmd.spawn()?;
78
+ let pgid = child.id() as i32;
79
+
80
+ let rx = if capture {
81
+ let stdout = child.stdout.take().expect("piped stdout");
82
+ let marker = cfg.url_marker.to_string();
83
+ let (tx, rx) = mpsc::channel::<String>();
84
+ std::thread::spawn(move || {
85
+ let mut sent = false;
86
+ for line in BufReader::new(stdout).lines().map_while(Result::ok) {
87
+ eprintln!("[server] {line}");
88
+ if !sent && line.contains(&marker) {
89
+ if let Some(url) = extract_url(&line) {
90
+ let _ = tx.send(url);
91
+ sent = true; // keep draining stdout so the child never blocks on a full pipe
92
+ }
93
+ }
94
+ }
95
+ });
96
+ Some(rx)
97
+ } else {
98
+ None
99
+ };
100
+
101
+ *slot().lock().unwrap() = Some(Running { child, pgid });
102
+ Ok(rx)
103
+ }
104
+
105
+ /// Poll `127.0.0.1:port` with a TCP connect until it accepts (server is listening) or the timeout
106
+ /// elapses. TCP-accept is a sufficient readiness signal — vite/preview binds the port only once it's
107
+ /// ready to serve. Returns false on timeout.
108
+ pub fn wait_for_port(port: u16, timeout_ms: u64) -> bool {
109
+ let deadline = Instant::now() + Duration::from_millis(timeout_ms);
110
+ let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into();
111
+ loop {
112
+ if TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok() {
113
+ return true;
114
+ }
115
+ if Instant::now() >= deadline {
116
+ return false;
117
+ }
118
+ std::thread::sleep(Duration::from_millis(300));
119
+ }
120
+ }
121
+
122
+ /// Signal the child's whole process group and reap (idempotent). Called on app exit so the local stack
123
+ /// never outlives the shell. SIGTERM first (lets the server run its exit-guard + reap its own children),
124
+ /// then a short grace, then SIGKILL as a backstop — the server may TRAP SIGTERM without exiting, and a
125
+ /// blocking `wait()` on such a child would hang the exit forever, so we hard-kill the group and only
126
+ /// `try_wait()` (non-blocking) to reap. The negative pgid targets every descendant.
127
+ pub fn shutdown() {
128
+ if let Some(m) = RUNNING.get() {
129
+ if let Some(mut r) = m.lock().unwrap().take() {
130
+ unsafe { libc::kill(-r.pgid, libc::SIGTERM) };
131
+ std::thread::sleep(Duration::from_millis(1500));
132
+ unsafe { libc::kill(-r.pgid, libc::SIGKILL) };
133
+ let _ = r.child.try_wait();
134
+ }
135
+ }
136
+ }
137
+
138
+ #[cfg(test)]
139
+ mod tests {
140
+ use super::extract_url;
141
+
142
+ #[test]
143
+ fn extracts_first_url_up_to_whitespace() {
144
+ assert_eq!(
145
+ extract_url(" Frontend: https://localhost:5050 (ready)"),
146
+ Some("https://localhost:5050".to_string())
147
+ );
148
+ assert_eq!(extract_url("Backend: http://localhost:5051"), Some("http://localhost:5051".to_string()));
149
+ }
150
+
151
+ #[test]
152
+ fn none_when_no_url() {
153
+ assert_eq!(extract_url("Frontend ready on port 5050"), None);
154
+ assert_eq!(extract_url(""), None);
155
+ }
156
+ }
package/src/cli.ts CHANGED
@@ -1979,6 +1979,30 @@ function desktopCopyFilter(src: string): boolean {
1979
1979
  return !/(^|\/)(target|gen|node_modules)(\/|$)/.test(rel);
1980
1980
  }
1981
1981
 
1982
+ /** Resolve a `desktop.server.command` to an absolute path: an absolute/relative path is resolved
1983
+ * against the app root; a bare name is looked up on `path` (build-time PATH). Falls back to the bare
1984
+ * name (the Rust shell will surface the spawn ENOENT) when it's nowhere. */
1985
+ function resolveServerCommand(command: string, cwd: string, path: string): string {
1986
+ if (command.includes('/')) return resolve(cwd, command);
1987
+ for (const dir of path.split(pathDelimiter).filter(Boolean)) {
1988
+ const abs = join(dir, command);
1989
+ if (existsSync(abs)) return abs;
1990
+ }
1991
+ return command;
1992
+ }
1993
+
1994
+ /** The bundled splash shown while a local-server app boots (before the shell navigates to the live
1995
+ * server). A dependency-free, theme-neutral holding page. */
1996
+ function desktopSplashHtml(name: string): string {
1997
+ const safe = name.replace(/</g, '&lt;').replace(/&/g, '&amp;');
1998
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${safe}</title>
1999
+ <style>html,body{height:100%;margin:0}body{display:flex;align-items:center;justify-content:center;
2000
+ flex-direction:column;gap:20px;background:#0b0b0f;color:#e5e5e5;font:14px -apple-system,system-ui,sans-serif}
2001
+ .s{width:34px;height:34px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:r .8s linear infinite}
2002
+ @keyframes r{to{transform:rotate(360deg)}}#e{color:#f87171;max-width:70%;text-align:center;line-height:1.5}</style>
2003
+ </head><body><div class="s" id="spin"></div><div id="m">Starting ${safe}…</div><div id="e"></div></body></html>`;
2004
+ }
2005
+
1982
2006
  /** Copy the desktop template into `native-desktop/` and stamp it from the config: the Rust-read
1983
2007
  * `shell_config.json` (identity + window), `tauri.conf.json` (productName/version/identifier +
1984
2008
  * frontendDist), and the staged web dist (`native-desktop/dist`, referenced as `../dist`). */
@@ -1991,19 +2015,39 @@ function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, push
1991
2015
  mkdirSync(outDir, { recursive: true });
1992
2016
  cpSync(DESKTOP_TEMPLATE_DIR, outDir, { recursive: true, force: true, filter: desktopCopyFilter });
1993
2017
 
1994
- // Stage the built web dist INTO the wrapper (mirrors mobile copyPwa) so frontendDist is a stable
1995
- // relative path independent of where the app project sits.
1996
- const dist = resolve(cwd, cfg.pwaDist);
1997
- if (!existsSync(dist)) {
1998
- console.error(`✖ Web dist not found at ${dist} — build the PWA first (or check pwaDist).`);
1999
- process.exit(1);
2000
- }
2001
- const stagedDist = join(outDir, 'dist');
2002
- rmSync(stagedDist, { recursive: true, force: true });
2003
- cpSync(dist, stagedDist, { recursive: true, force: true });
2004
-
2005
2018
  const shell = deriveDesktopConfig(cfg);
2006
2019
  shell.pushSigned = pushSigned;
2020
+
2021
+ // Stage the frontendDist INTO the wrapper (mirrors mobile copyPwa) so it's a stable relative path
2022
+ // independent of where the app project sits. Local-server apps (`desktop.server`) have no meaningful
2023
+ // web dist — the window loads the live server — so we stage a tiny bundled SPLASH instead (shown
2024
+ // while the server boots, then the shell navigates away from it).
2025
+ const stagedDist = join(outDir, 'dist');
2026
+ rmSync(stagedDist, { recursive: true, force: true });
2027
+ if (shell.server) {
2028
+ mkdirSync(stagedDist, { recursive: true });
2029
+ writeFileSync(join(stagedDist, 'index.html'), desktopSplashHtml(shell.name));
2030
+ } else {
2031
+ const dist = resolve(cwd, cfg.pwaDist);
2032
+ if (!existsSync(dist)) {
2033
+ console.error(`✖ Web dist not found at ${dist} — build the PWA first (or check pwaDist).`);
2034
+ process.exit(1);
2035
+ }
2036
+ cpSync(dist, stagedDist, { recursive: true, force: true });
2037
+ }
2038
+
2039
+ // Local-server mode: resolve `command` (bare name → PATH lookup) and `cwd` to ABSOLUTE, and stamp
2040
+ // the build-time PATH — a GUI-launched .app inherits the minimal launchd PATH (no ~/.bun/bin), so
2041
+ // a bare-name spawn (or a child that itself shells out to `bun`/`node`) would fail there.
2042
+ if (shell.server) {
2043
+ const s = shell.server;
2044
+ s.cwd = s.cwd ? resolve(cwd, s.cwd) : cwd;
2045
+ s.command = resolveServerCommand(s.command, cwd, process.env.PATH ?? '');
2046
+ s.path = process.env.PATH ?? '';
2047
+ if (!existsSync(s.command)) {
2048
+ console.warn(`⚠ desktop.server.command not found (${s.command}) — the server will fail to spawn at launch.`);
2049
+ }
2050
+ }
2007
2051
  // Resolve the handlers path to ABSOLUTE (against the app root): the shell binary runs from a
2008
2052
  // different cwd, and for `build desktop` the .app runs from wherever it's installed — an absolute
2009
2053
  // path is the only one that stays valid. The handler file is NOT copied into the wrapper/.app; the
@@ -2347,7 +2391,9 @@ async function buildDesktop(cwd: string, flags: Record<string, string>): Promise
2347
2391
  ensureDesktopMacOS();
2348
2392
  const cfg = await loadConfig(cwd, flags);
2349
2393
  const outDir = resolve(cwd, flags.out ?? 'native-desktop');
2350
- buildWebIfBundled(cwd, cfg, flags);
2394
+ // Local-server apps load the live server, not a bundled web build — skip it (regenerateDesktop
2395
+ // stages a boot splash for them). Otherwise build/stage the PWA as usual.
2396
+ if (!cfg.desktop?.server) buildWebIfBundled(cwd, cfg, flags);
2351
2397
  // Resolve the signing lane BEFORE regeneration: shell_config.json is embedded at compile time,
2352
2398
  // and the Rust shell's push capability must only claim 'native' when the .app really ships the
2353
2399
  // profile-backed aps-environment entitlement (signed lane + installed macOS profile).
@@ -3524,10 +3570,21 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
3524
3570
  console.log(`▶ watching web logs from ${cfg.id} on ${device.name} (pull every 3s) — Ctrl-C to stop.`);
3525
3571
  console.log(' [appwrap-web] = forwarded WebView console/errors. (--once = snapshot, --native = OS firehose.)');
3526
3572
  let shown = 0;
3573
+ // Self-terminate when orphaned: if our controlling session dies, macOS reparents us to launchd and
3574
+ // this loop would otherwise poll `devicectl` FOREVER — every stale session stacking load until
3575
+ // CoreDeviceService pins a core at 100%. NOTE: `process.ppid` is cached at startup and does NOT
3576
+ // update on reparent, so probe the ORIGINAL parent's liveness with signal 0 (throws once it's gone);
3577
+ // also stop on a broken stdout pipe (consumer gone).
3578
+ const parentPid = process.ppid;
3579
+ const orphaned = (): boolean => { try { process.kill(parentPid, 0); return false; } catch { return true; } };
3527
3580
  for (;;) {
3581
+ if (orphaned()) break; // original parent gone → we've been reparented → stop
3528
3582
  const all = pull();
3529
3583
  if (all.length < shown) shown = 0; // app relaunched → file reset; reprint
3530
- if (all.length > shown) { process.stdout.write(all.slice(shown)); shown = all.length; }
3584
+ if (all.length > shown) {
3585
+ try { process.stdout.write(all.slice(shown)); } catch { break; } // pipe closed → consumer gone
3586
+ shown = all.length;
3587
+ }
3531
3588
  try { execFileSync('sleep', ['3']); } catch { break; }
3532
3589
  }
3533
3590
  }