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