@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.
- package/package.json +1 -1
- package/runtime-desktop/bridge-shim/build.sh +9 -0
- package/runtime-desktop/bridge-shim/content-entry.ts +87 -0
- package/runtime-desktop/bridge-shim/shim.ts +317 -0
- package/runtime-desktop/src-tauri/Cargo.lock +2 -0
- package/runtime-desktop/src-tauri/Cargo.toml +2 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +664 -0
- package/runtime-desktop/src-tauri/src/main.rs +198 -2
- package/runtime-desktop/src-tauri/src/popup_mac.rs +63 -10
- package/runtime-desktop/src-tauri/src/server.rs +156 -0
- package/src/cli.ts +70 -13
- package/src/config.ts +36 -0
- package/src/desktop.ts +48 -1
|
@@ -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
|
-
.
|
|
682
|
-
.expect("error while
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
147
|
-
|
|
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, '<').replace(/&/g, '&');
|
|
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
|
-
|
|
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) {
|
|
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
|
}
|