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