@livx.cc/appwrap 0.42.3 → 0.43.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 +4 -2
- package/runtime/app/app.ts +15 -0
- package/runtime/app/main-page.ts +11 -2
- package/runtime/app/shell/custom-webview.android.ts +81 -46
- package/runtime/app/shell/custom-webview.ios.ts +18 -8
- package/runtime/app/shell/handlers-android.ts +25 -6
- package/runtime/app/shell/handlers-background.ts +1 -1
- package/runtime/app/shell/web-quirks.ts +63 -0
- package/runtime/webpack.config.js +20 -0
- package/runtime-desktop/autotest/index.html +77 -0
- package/runtime-desktop/src-tauri/Cargo.lock +5459 -0
- package/runtime-desktop/src-tauri/Cargo.toml +38 -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/main.rs +683 -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 +466 -0
- package/runtime-desktop/src-tauri/src/push_mac.rs +193 -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 +621 -48
- package/src/config.ts +40 -1
- package/src/desktop.ts +246 -0
- package/src/handlers.ts +71 -0
- package/src/icon.ts +207 -0
|
@@ -0,0 +1,683 @@
|
|
|
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 notifications_mac;
|
|
20
|
+
#[cfg(target_os = "macos")]
|
|
21
|
+
mod oauth_mac;
|
|
22
|
+
#[cfg(target_os = "macos")]
|
|
23
|
+
mod popup_mac;
|
|
24
|
+
#[cfg(target_os = "macos")]
|
|
25
|
+
mod push_mac;
|
|
26
|
+
#[cfg(target_os = "macos")]
|
|
27
|
+
mod sidecar;
|
|
28
|
+
|
|
29
|
+
static STORAGE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
|
|
30
|
+
|
|
31
|
+
/// App handle, set once in setup() — lets off-main-thread handlers (e.g. `toast.show`) reach
|
|
32
|
+
/// plugin APIs like tauri-plugin-notification, which need a `Manager` to resolve their state.
|
|
33
|
+
static APP: OnceLock<tauri::AppHandle> = OnceLock::new();
|
|
34
|
+
|
|
35
|
+
/// Single in-flight `oauth.authorize` guard (mirrors the mobile shells' one-session model): a second
|
|
36
|
+
/// authorize while one is pending → NATIVE_ERROR. The ASWebAuthenticationSession completion handler
|
|
37
|
+
/// (oauth_mac) resolves/cancels the flow and clears this.
|
|
38
|
+
static OAUTH_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
|
39
|
+
|
|
40
|
+
/// Cold-vs-warm deep-link state, under ONE lock: `handshaken` flips on the first `app.handshake`
|
|
41
|
+
/// (after which links are WARM → `deeplink.open` events); `pending` holds a link that arrived
|
|
42
|
+
/// before it (cold launch FROM a link), carried back as `deepLink` in the handshake response
|
|
43
|
+
/// (mobile parity) so the PWA can route before first paint. A single mutex — not an atomic flag
|
|
44
|
+
/// beside a mutex — so the handshake's read-flag+drain and on_open_url's check-then-stash can't
|
|
45
|
+
/// interleave and strand a link that arrives at the exact handshake instant.
|
|
46
|
+
struct DeepLinkState { handshaken: bool, pending: Option<String> }
|
|
47
|
+
static DEEPLINK: Mutex<DeepLinkState> = Mutex::new(DeepLinkState { handshaken: false, pending: None });
|
|
48
|
+
|
|
49
|
+
/// App identity + window shape, stamped by `appwrap dev|build desktop` from the appwrap config into
|
|
50
|
+
/// `shell_config.json` (embedded at compile time). De-hardcodes what the spike baked in. Mirrors
|
|
51
|
+
/// `DesktopShellConfig` in packages/appwrap-cli/src/desktop.ts.
|
|
52
|
+
#[derive(serde::Deserialize)]
|
|
53
|
+
#[serde(rename_all = "camelCase")]
|
|
54
|
+
struct ShellConfig {
|
|
55
|
+
app_id: String,
|
|
56
|
+
name: String,
|
|
57
|
+
version: String,
|
|
58
|
+
title: String,
|
|
59
|
+
width: f64,
|
|
60
|
+
height: f64,
|
|
61
|
+
url_scheme: String,
|
|
62
|
+
identifier: String,
|
|
63
|
+
/// Persist + restore window size/position across launches (tauri-plugin-window-state). When
|
|
64
|
+
/// false the plugin isn't registered, so the configured width/height always apply.
|
|
65
|
+
#[serde(default = "default_true")]
|
|
66
|
+
restore_window_state: bool,
|
|
67
|
+
/// Append `prompt=select_account` to Google `signInWithPopup` OAuth popup URLs (see popup_mac).
|
|
68
|
+
#[serde(default)]
|
|
69
|
+
google_account_picker: bool,
|
|
70
|
+
/// Launch on login (tauri-plugin-autostart, macOS LaunchAgent). On setup the shell enables the
|
|
71
|
+
/// login item when true and explicitly disables it when false — so flipping the config off
|
|
72
|
+
/// removes the item on the next launch.
|
|
73
|
+
#[serde(default)]
|
|
74
|
+
autostart: bool,
|
|
75
|
+
/// Absolute path to the app's custom-handlers TS file (stamped by the CLI). When non-empty the shell
|
|
76
|
+
/// spawns a `bun <path>` sidecar at setup and routes methods the sidecar advertises to it (see
|
|
77
|
+
/// sidecar.rs). Empty/absent → the feature is fully inert (no sidecar).
|
|
78
|
+
#[serde(default)]
|
|
79
|
+
handlers: String,
|
|
80
|
+
/// Absolute path to the Bun binary to spawn the sidecar with (the CLI stamps its own
|
|
81
|
+
/// `process.execPath`). A GUI-launched .app inherits the launchd PATH (no ~/.bun/bin), so a
|
|
82
|
+
/// bare `bun` lookup would fail there; sidecar.rs falls back to `bun` when this is empty/gone.
|
|
83
|
+
#[serde(default)]
|
|
84
|
+
handlers_runtime: String,
|
|
85
|
+
/// True when the CLI resolved a real signing identity + macOS provisioning profile for this
|
|
86
|
+
/// build — i.e. the .app carries the aps-environment entitlement, so APNs registration can
|
|
87
|
+
/// actually succeed. Gates push.register and the handshake's push capability (adhoc → 'none').
|
|
88
|
+
#[serde(default)]
|
|
89
|
+
push_signed: bool,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn default_true() -> bool { true }
|
|
93
|
+
|
|
94
|
+
fn shell() -> &'static ShellConfig {
|
|
95
|
+
static CFG: OnceLock<ShellConfig> = OnceLock::new();
|
|
96
|
+
CFG.get_or_init(|| {
|
|
97
|
+
serde_json::from_str(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/shell_config.json")))
|
|
98
|
+
.expect("invalid shell_config.json")
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const INIT_JS: &str = r#"
|
|
103
|
+
(() => {
|
|
104
|
+
const invoke = window.__TAURI_INTERNALS__.invoke;
|
|
105
|
+
window.appwrapNative = {
|
|
106
|
+
postMessage: (json) => { invoke('appwrap_invoke', { envelope: json }); },
|
|
107
|
+
};
|
|
108
|
+
})();
|
|
109
|
+
"#;
|
|
110
|
+
|
|
111
|
+
type HandlerResult = Result<Value, (&'static str, String)>;
|
|
112
|
+
|
|
113
|
+
/// spawn() without wait() leaks a zombie per call; reap in a detached thread.
|
|
114
|
+
fn spawn_reaped(mut cmd: std::process::Command) -> Result<(), (&'static str, String)> {
|
|
115
|
+
let mut child = cmd.spawn().map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
|
|
116
|
+
std::thread::spawn(move || { let _ = child.wait(); });
|
|
117
|
+
Ok(())
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Show a real OS notification (tauri-plugin-notification). Checks/asks permission first and errors
|
|
121
|
+
/// (not panics) on a missing app handle, denied permission, or delivery failure — the caller decides
|
|
122
|
+
/// how to degrade. On macOS the plugin routes through the app bundle's identity, so Notification
|
|
123
|
+
/// Center attributes it to the shell; unbundled `cargo run` may fail to deliver (no bundle id) → logged.
|
|
124
|
+
fn show_notification(title: &str, body: &str) -> Result<(), String> {
|
|
125
|
+
use tauri::plugin::PermissionState;
|
|
126
|
+
use tauri_plugin_notification::NotificationExt;
|
|
127
|
+
let app = APP.get().ok_or("app handle not ready")?;
|
|
128
|
+
let notifier = app.notification();
|
|
129
|
+
let state = match notifier.permission_state().map_err(|e| e.to_string())? {
|
|
130
|
+
PermissionState::Prompt | PermissionState::PromptWithRationale => {
|
|
131
|
+
notifier.request_permission().map_err(|e| e.to_string())?
|
|
132
|
+
}
|
|
133
|
+
s => s,
|
|
134
|
+
};
|
|
135
|
+
if state != PermissionState::Granted {
|
|
136
|
+
return Err(format!("permission not granted ({state:?})"));
|
|
137
|
+
}
|
|
138
|
+
notifier
|
|
139
|
+
.builder()
|
|
140
|
+
.title(title)
|
|
141
|
+
.body(body)
|
|
142
|
+
.show()
|
|
143
|
+
.map_err(|e| e.to_string())
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// Set (Some(n)) or clear (None) the Dock-icon badge on the main window. Resolves the window via the
|
|
147
|
+
/// stashed app handle so the worker-thread handlers can reach it; the tauri call posts to the event
|
|
148
|
+
/// loop, so it's safe off the main thread.
|
|
149
|
+
fn set_badge(count: Option<i64>) -> Result<(), (&'static str, String)> {
|
|
150
|
+
let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
|
|
151
|
+
let window = app
|
|
152
|
+
.get_webview_window("main")
|
|
153
|
+
.ok_or(("NATIVE_ERROR", "no main window".to_string()))?;
|
|
154
|
+
window
|
|
155
|
+
.set_badge_count(count)
|
|
156
|
+
.map_err(|e| ("NATIVE_ERROR", e.to_string()))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Request user attention on the main window (macOS: bounce the Dock icon). `critical` bounces until
|
|
160
|
+
/// the app gains focus; otherwise a single informational bounce. Like `set_badge`, the underlying
|
|
161
|
+
/// `request_user_attention` posts to the event loop (send_user_message), so it's safe off the main
|
|
162
|
+
/// thread — no `run_on_main_thread` needed.
|
|
163
|
+
fn request_attention(critical: bool) -> Result<(), (&'static str, String)> {
|
|
164
|
+
let app = APP.get().ok_or(("NATIVE_ERROR", "app handle not ready".to_string()))?;
|
|
165
|
+
let window = app
|
|
166
|
+
.get_webview_window("main")
|
|
167
|
+
.ok_or(("NATIVE_ERROR", "no main window".to_string()))?;
|
|
168
|
+
let kind = if critical {
|
|
169
|
+
tauri::UserAttentionType::Critical
|
|
170
|
+
} else {
|
|
171
|
+
tauri::UserAttentionType::Informational
|
|
172
|
+
};
|
|
173
|
+
window
|
|
174
|
+
.request_user_attention(Some(kind))
|
|
175
|
+
.map_err(|e| ("NATIVE_ERROR", e.to_string()))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Method-namespace prefixes served by the built-in `handle()` below (kept next to it — add the
|
|
179
|
+
/// prefix here when you add a new built-in family). sidecar::init REJECTS any sidecar claim under
|
|
180
|
+
/// these, so an app handler can never shadow a built-in (e.g. a custom `storage.get`).
|
|
181
|
+
pub const BUILTIN_METHOD_PREFIXES: &[&str] = &[
|
|
182
|
+
"app.", "biometrics.", "browser.", "clipboard.", "deeplink.", "device.", "network.",
|
|
183
|
+
"notifications.", "oauth.", "push.", "storage.", "toast.", "ui.",
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
fn handle(method: &str, params: &Value) -> HandlerResult {
|
|
187
|
+
match method {
|
|
188
|
+
"app.handshake" => {
|
|
189
|
+
// One critical section: flip to warm AND drain any cold link together (see DEEPLINK).
|
|
190
|
+
let cold_link = {
|
|
191
|
+
let mut dl = DEEPLINK.lock().unwrap();
|
|
192
|
+
dl.handshaken = true;
|
|
193
|
+
dl.pending.take()
|
|
194
|
+
};
|
|
195
|
+
let mut resp = json!({
|
|
196
|
+
"protocol": 1,
|
|
197
|
+
"platform": "desktop",
|
|
198
|
+
"app": {
|
|
199
|
+
"id": shell().app_id,
|
|
200
|
+
"name": shell().name,
|
|
201
|
+
"version": shell().version,
|
|
202
|
+
"loader": "app"
|
|
203
|
+
},
|
|
204
|
+
// Explicit 'none' = veto (kit will NOT web-upgrade it): things a desktop
|
|
205
|
+
// genuinely lacks. Keys OMITTED here degrade to the kit's WebAdapter
|
|
206
|
+
// (share, geo, speech, media, ...) per the hybrid-fallback merge.
|
|
207
|
+
"capabilities": {
|
|
208
|
+
"clipboard": "native",
|
|
209
|
+
"toast": "native",
|
|
210
|
+
"device": "native",
|
|
211
|
+
"storage": "native",
|
|
212
|
+
"app": "native",
|
|
213
|
+
"browser": "native",
|
|
214
|
+
"network": "native",
|
|
215
|
+
"ui": "native",
|
|
216
|
+
"deeplinks": "native",
|
|
217
|
+
"oauth": "native",
|
|
218
|
+
// Touch ID via LocalAuthentication. The kit's WebAdapter stub answers available:
|
|
219
|
+
// false / authenticate→UNSUPPORTED (web-adapter.ts), so OMITTING the key would peg
|
|
220
|
+
// the module to a permanent no-op. We DO have a real native lane, so 'native' is the
|
|
221
|
+
// honest value — and it stays honest whether or not THIS Mac has enrolled hardware
|
|
222
|
+
// (biometrics.available then returns available:false, a valid native response).
|
|
223
|
+
"biometrics": "native",
|
|
224
|
+
// Dock-icon badge is native (notifications.setBadge/clear → NSDockTile); this is the
|
|
225
|
+
// key `kit.app.badgeCapability` reads. `notifications` is 'native' too: schedule/
|
|
226
|
+
// pending/requestPermission are intercepted below and served by notifications_mac
|
|
227
|
+
// (UNUserNotificationCenter — the only lane whose tap callback can route a stamped
|
|
228
|
+
// deepLink; the web Notification API fallback never fires since these never return
|
|
229
|
+
// UNSUPPORTED).
|
|
230
|
+
"notifications": "native",
|
|
231
|
+
"badge": "native",
|
|
232
|
+
"haptics": "none",
|
|
233
|
+
// 'native' ONLY when the CLI stamped push_signed (signed .app with an embedded
|
|
234
|
+
// profile backing aps-environment) — an adhoc build can never get an APNs token.
|
|
235
|
+
"push": if shell().push_signed { "native" } else { "none" },
|
|
236
|
+
"motion": "none"
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
// Cold launch FROM a deep link: hand the launching URL back synchronously (mobile parity —
|
|
240
|
+
// see `deepLink` on the kit's Handshake type) so the PWA routes before first paint.
|
|
241
|
+
if let Some(link) = cold_link {
|
|
242
|
+
resp["deepLink"] = json!(link);
|
|
243
|
+
}
|
|
244
|
+
Ok(resp)
|
|
245
|
+
}
|
|
246
|
+
"app.environment" => Ok(json!({
|
|
247
|
+
"platform": "desktop",
|
|
248
|
+
"os": std::env::consts::OS,
|
|
249
|
+
"arch": std::env::consts::ARCH,
|
|
250
|
+
"debug": cfg!(debug_assertions)
|
|
251
|
+
})),
|
|
252
|
+
"app.canOpenUrl" => Ok(json!({ "ok": params["url"].as_str().is_some() })),
|
|
253
|
+
"app.openUrl" | "browser.open" => {
|
|
254
|
+
let url = params["url"].as_str().ok_or(("NATIVE_ERROR", "missing url".to_string()))?;
|
|
255
|
+
if !(url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")) {
|
|
256
|
+
return Err(("DENIED", format!("scheme not allowed: {url}")));
|
|
257
|
+
}
|
|
258
|
+
let mut cmd = std::process::Command::new("open");
|
|
259
|
+
cmd.arg(url);
|
|
260
|
+
spawn_reaped(cmd)?;
|
|
261
|
+
Ok(json!({ "ok": true }))
|
|
262
|
+
}
|
|
263
|
+
// Bounce the Dock icon to request attention (macOS). `critical` (default false) bounces until
|
|
264
|
+
// the app regains focus; otherwise a single bounce. No kit capability key advertises this —
|
|
265
|
+
// apps call it directly via kit.invoke('app.requestAttention'). Routes to request_user_attention
|
|
266
|
+
// which posts to the event loop, so it's thread-safe from this worker thread.
|
|
267
|
+
"app.requestAttention" => {
|
|
268
|
+
let critical = params["critical"].as_bool().unwrap_or(false);
|
|
269
|
+
request_attention(critical)?;
|
|
270
|
+
Ok(json!({ "ok": true }))
|
|
271
|
+
}
|
|
272
|
+
"app.openSettings" => {
|
|
273
|
+
let mut cmd = std::process::Command::new("open");
|
|
274
|
+
cmd.arg("x-apple.systempreferences:");
|
|
275
|
+
spawn_reaped(cmd)?;
|
|
276
|
+
Ok(json!({ "ok": true }))
|
|
277
|
+
}
|
|
278
|
+
"network.status" => Ok(json!({ "online": true, "type": "wifi" })),
|
|
279
|
+
"ui.safeArea" => Ok(json!({ "top": 0, "right": 0, "bottom": 0, "left": 0 })),
|
|
280
|
+
"ui.alert" | "ui.confirm" => {
|
|
281
|
+
let message = params["message"].as_str().unwrap_or("").replace('"', "\\\"");
|
|
282
|
+
let title = params["title"].as_str().unwrap_or(&shell().name).replace('"', "\\\"");
|
|
283
|
+
let buttons = if method == "ui.confirm" { "{\"Cancel\", \"OK\"}" } else { "{\"OK\"}" };
|
|
284
|
+
let script = format!(
|
|
285
|
+
"display dialog \"{message}\" with title \"{title}\" buttons {buttons} default button -1"
|
|
286
|
+
);
|
|
287
|
+
let out = std::process::Command::new("osascript")
|
|
288
|
+
.args(["-e", &script])
|
|
289
|
+
.output()
|
|
290
|
+
.map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
|
|
291
|
+
// osascript exits 1 on Cancel; that's a `false` confirm, not an error.
|
|
292
|
+
let confirmed = out.status.success();
|
|
293
|
+
Ok(json!({ "ok": true, "confirmed": confirmed }))
|
|
294
|
+
}
|
|
295
|
+
"device.info" => Ok(json!({
|
|
296
|
+
"platform": "desktop",
|
|
297
|
+
"os": std::env::consts::OS,
|
|
298
|
+
"arch": std::env::consts::ARCH,
|
|
299
|
+
"model": "mac",
|
|
300
|
+
})),
|
|
301
|
+
"clipboard.copy" => {
|
|
302
|
+
let text = params["text"].as_str().unwrap_or_default().to_string();
|
|
303
|
+
arboard::Clipboard::new()
|
|
304
|
+
.and_then(|mut c| c.set_text(text))
|
|
305
|
+
.map_err(|e| ("NATIVE_ERROR", e.to_string()))?;
|
|
306
|
+
Ok(json!({ "ok": true }))
|
|
307
|
+
}
|
|
308
|
+
"clipboard.read" => {
|
|
309
|
+
let text = arboard::Clipboard::new()
|
|
310
|
+
.and_then(|mut c| c.get_text())
|
|
311
|
+
.unwrap_or_default();
|
|
312
|
+
Ok(json!({ "text": text }))
|
|
313
|
+
}
|
|
314
|
+
"toast.show" => {
|
|
315
|
+
let message = params["message"]
|
|
316
|
+
.as_str()
|
|
317
|
+
.or_else(|| params["text"].as_str())
|
|
318
|
+
.unwrap_or("");
|
|
319
|
+
// Real OS notification via tauri-plugin-notification — proper Notification Center
|
|
320
|
+
// identity from the bundle id (no shell-escaping / osascript hop). Graceful degrade:
|
|
321
|
+
// a denied permission or delivery failure logs and still returns ok (a toast is
|
|
322
|
+
// best-effort UX, never a hard error to the caller).
|
|
323
|
+
if let Err(e) = show_notification(&shell().name, message) {
|
|
324
|
+
eprintln!("[bridge] toast.show notification skipped: {e}");
|
|
325
|
+
}
|
|
326
|
+
Ok(json!({ "ok": true }))
|
|
327
|
+
}
|
|
328
|
+
// Dock-icon badge via the tauri window badge API (macOS NSDockTile). setBadge with count>0
|
|
329
|
+
// shows the number; count 0 (and `clear`) removes it. schedule/pending/requestPermission are
|
|
330
|
+
// NOT implemented here → they return UNSUPPORTED below and the kit degrades them to its
|
|
331
|
+
// WebAdapter (Notification API). `set_badge_count` posts to the event loop (thread-safe from
|
|
332
|
+
// this worker thread). `clear` is badge-only on desktop (no delivered-notification store).
|
|
333
|
+
"notifications.setBadge" => {
|
|
334
|
+
let count = params["count"].as_i64().unwrap_or(0);
|
|
335
|
+
set_badge(if count > 0 { Some(count) } else { None })?;
|
|
336
|
+
Ok(json!({ "ok": true }))
|
|
337
|
+
}
|
|
338
|
+
"notifications.clear" => {
|
|
339
|
+
set_badge(None)?;
|
|
340
|
+
Ok(json!({ "ok": true }))
|
|
341
|
+
}
|
|
342
|
+
m if m.starts_with("storage.") => storage_handle(m, params),
|
|
343
|
+
_ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/// Persisted-store file: ~/Library/Application Support/<identifier>/storage.json (macOS app-data dir).
|
|
348
|
+
fn storage_path() -> std::path::PathBuf {
|
|
349
|
+
let home = std::env::var("HOME").unwrap_or_default();
|
|
350
|
+
std::path::Path::new(&home)
|
|
351
|
+
.join("Library/Application Support")
|
|
352
|
+
.join(&shell().identifier)
|
|
353
|
+
.join("storage.json")
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fn load_store() -> HashMap<String, String> {
|
|
357
|
+
std::fs::read_to_string(storage_path())
|
|
358
|
+
.ok()
|
|
359
|
+
.and_then(|s| serde_json::from_str(&s).ok())
|
|
360
|
+
.unwrap_or_default()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
fn save_store(store: &HashMap<String, String>) -> Result<(), String> {
|
|
364
|
+
let path = storage_path();
|
|
365
|
+
if let Some(dir) = path.parent() {
|
|
366
|
+
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
|
367
|
+
}
|
|
368
|
+
let data = serde_json::to_string(store).map_err(|e| e.to_string())?;
|
|
369
|
+
std::fs::write(&path, data).map_err(|e| e.to_string())
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// storage.* — persisted to a JSON file in the macOS app-data dir (write-through, in-memory cache).
|
|
373
|
+
/// Note secure.* rides the SAME plaintext file: desktop parity is "persistent", not encrypted (a
|
|
374
|
+
/// Keychain-backed secure lane is out of scope for phase-0).
|
|
375
|
+
fn storage_handle(method: &str, params: &Value) -> HandlerResult {
|
|
376
|
+
let mut guard = STORAGE.lock().unwrap();
|
|
377
|
+
let store = guard.get_or_insert_with(load_store);
|
|
378
|
+
let key = params["key"].as_str().unwrap_or_default();
|
|
379
|
+
let persist = |store: &HashMap<String, String>| -> HandlerResult {
|
|
380
|
+
save_store(store).map_err(|e| ("NATIVE_ERROR", e))?;
|
|
381
|
+
Ok(json!({ "ok": true }))
|
|
382
|
+
};
|
|
383
|
+
match method {
|
|
384
|
+
"storage.get" | "storage.secure.get" => Ok(json!({ "value": store.get(key) })),
|
|
385
|
+
"storage.set" | "storage.secure.set" => {
|
|
386
|
+
let value = params["value"].as_str().unwrap_or_default().to_string();
|
|
387
|
+
store.insert(key.to_string(), value);
|
|
388
|
+
persist(store)
|
|
389
|
+
}
|
|
390
|
+
"storage.remove" | "storage.secure.remove" => {
|
|
391
|
+
store.remove(key);
|
|
392
|
+
persist(store)
|
|
393
|
+
}
|
|
394
|
+
"storage.clear" => {
|
|
395
|
+
store.clear();
|
|
396
|
+
persist(store)
|
|
397
|
+
}
|
|
398
|
+
_ => Err(("UNSUPPORTED", format!("{method} not implemented on desktop"))),
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#[tauri::command]
|
|
403
|
+
fn appwrap_invoke(window: tauri::WebviewWindow, envelope: String) {
|
|
404
|
+
let req: Value = match serde_json::from_str(&envelope) {
|
|
405
|
+
Ok(v) => v,
|
|
406
|
+
Err(_) => return,
|
|
407
|
+
};
|
|
408
|
+
if req["kind"] != "request" {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
let id = req["id"].as_str().unwrap_or_default().to_string();
|
|
412
|
+
let method = req["method"].as_str().unwrap_or_default().to_string();
|
|
413
|
+
eprintln!("[bridge] {id} {method}");
|
|
414
|
+
let params = req["params"].clone();
|
|
415
|
+
// oauth.authorize replies asynchronously from the ASWebAuthenticationSession completion handler
|
|
416
|
+
// (must start on the main thread; the sheet stays up for minutes) — not from the worker thread.
|
|
417
|
+
if method == "oauth.authorize" {
|
|
418
|
+
let url = params["url"].as_str().unwrap_or_default().to_string();
|
|
419
|
+
let scheme = params["callbackScheme"].as_str().unwrap_or_default().to_string();
|
|
420
|
+
let ephemeral = params["ephemeral"].as_bool().unwrap_or(false);
|
|
421
|
+
if url.is_empty() || scheme.is_empty() {
|
|
422
|
+
deliver_error(&window, &id, "NATIVE_ERROR", "oauth.authorize: empty url or callbackScheme");
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if OAUTH_IN_FLIGHT.swap(true, Ordering::SeqCst) {
|
|
426
|
+
deliver_error(&window, &id, "NATIVE_ERROR", "oauth.authorize: a flow is already in progress");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
#[cfg(target_os = "macos")]
|
|
430
|
+
oauth_mac::start(window, id, url, scheme, ephemeral);
|
|
431
|
+
#[cfg(not(target_os = "macos"))]
|
|
432
|
+
{
|
|
433
|
+
let _ = (url, scheme, ephemeral);
|
|
434
|
+
OAUTH_IN_FLIGHT.store(false, Ordering::SeqCst);
|
|
435
|
+
deliver_error(&window, &id, "UNSUPPORTED", "oauth.authorize not implemented on this desktop OS");
|
|
436
|
+
}
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// push.register/unregister reply asynchronously from the APNs delegate callbacks (main thread) —
|
|
440
|
+
// and are only honest on a signed build (push_signed): adhoc has no aps entitlement, so
|
|
441
|
+
// registerForRemoteNotifications would just fail. Mirrors the mobile shell's envelope.
|
|
442
|
+
if method == "push.register" || method == "push.unregister" {
|
|
443
|
+
if !shell().push_signed {
|
|
444
|
+
deliver_error(&window, &id, "UNSUPPORTED",
|
|
445
|
+
"push needs a signed desktop build (set teamId/APPWRAP_TEAM_ID with an installed macOS provisioning profile)");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
#[cfg(target_os = "macos")]
|
|
449
|
+
{
|
|
450
|
+
if method == "push.register" { push_mac::register(window, id); }
|
|
451
|
+
else { push_mac::unregister(window, id); }
|
|
452
|
+
}
|
|
453
|
+
#[cfg(not(target_os = "macos"))]
|
|
454
|
+
deliver_error(&window, &id, "UNSUPPORTED", "push not implemented on this desktop OS");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// biometrics.* — LocalAuthentication. `available` is a synchronous query; `authenticate` shows the
|
|
458
|
+
// Touch ID sheet and replies asynchronously from the LA completion block (main thread). Both live
|
|
459
|
+
// here (not in handle()) so the sheet starts off-worker-thread and the reply rides the window.
|
|
460
|
+
if method.starts_with("biometrics.") {
|
|
461
|
+
#[cfg(target_os = "macos")]
|
|
462
|
+
{
|
|
463
|
+
match method.as_str() {
|
|
464
|
+
"biometrics.available" => {
|
|
465
|
+
let resp = json!({ "v": 1, "id": id, "kind": "response", "result": biometrics_mac::available() });
|
|
466
|
+
deliver(&window, &resp);
|
|
467
|
+
}
|
|
468
|
+
"biometrics.authenticate" => {
|
|
469
|
+
let reason = params["reason"].as_str().unwrap_or_default().to_string();
|
|
470
|
+
biometrics_mac::authenticate(window, id, reason);
|
|
471
|
+
}
|
|
472
|
+
_ => deliver_error(&window, &id, "UNSUPPORTED", &format!("{method} not implemented on desktop")),
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
#[cfg(not(target_os = "macos"))]
|
|
476
|
+
deliver_error(&window, &id, "UNSUPPORTED", "biometrics not implemented on this desktop OS");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// notifications.requestPermission / .schedule / .pending reply asynchronously from
|
|
480
|
+
// UNUserNotificationCenter completion blocks (started on the main thread) — so they intercept here,
|
|
481
|
+
// like biometrics/oauth. setBadge/clear are synchronous and fall through to handle() below.
|
|
482
|
+
#[cfg(target_os = "macos")]
|
|
483
|
+
if matches!(method.as_str(), "notifications.requestPermission" | "notifications.schedule" | "notifications.pending") {
|
|
484
|
+
match method.as_str() {
|
|
485
|
+
"notifications.requestPermission" => notifications_mac::request_permission(window, id),
|
|
486
|
+
"notifications.schedule" => notifications_mac::schedule(window, id, params),
|
|
487
|
+
"notifications.pending" => notifications_mac::pending(window, id),
|
|
488
|
+
_ => unreachable!(),
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
// Off the main thread: handlers may block (clipboard, subprocess).
|
|
493
|
+
std::thread::spawn(move || {
|
|
494
|
+
// The app's own url scheme is an INTERNAL deep link (mobile-shell parity):
|
|
495
|
+
// loop it back as a `deeplink.open` event instead of shelling out to `open`.
|
|
496
|
+
let scheme = shell().url_scheme.as_str();
|
|
497
|
+
if !scheme.is_empty()
|
|
498
|
+
&& (method == "app.openUrl" || method == "browser.open")
|
|
499
|
+
&& params["url"].as_str().is_some_and(|u| u.starts_with(&format!("{scheme}://")))
|
|
500
|
+
{
|
|
501
|
+
let event = json!({ "v": 1, "kind": "event", "event": "deeplink.open",
|
|
502
|
+
"payload": { "url": params["url"] } });
|
|
503
|
+
deliver(&window, &event);
|
|
504
|
+
let resp = json!({ "v": 1, "id": id, "kind": "response", "result": { "ok": true } });
|
|
505
|
+
deliver(&window, &resp);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
// App-custom methods: forward to the Bun sidecar (desktop.handlers) if it advertised this
|
|
509
|
+
// method. Built-in collisions are ENFORCED away: sidecar::init drops any claim under
|
|
510
|
+
// BUILTIN_METHOD_PREFIXES (below), so a custom `storage.get` can never shadow handle().
|
|
511
|
+
// The sidecar keeps its own (dynamic) error codes — so build the envelope here rather than
|
|
512
|
+
// routing through handle()'s &'static-str-coded HandlerResult.
|
|
513
|
+
#[cfg(target_os = "macos")]
|
|
514
|
+
if sidecar::claims(&method) {
|
|
515
|
+
let resp = match sidecar::request(&method, ¶ms) {
|
|
516
|
+
Ok(result) => json!({ "v": 1, "id": id, "kind": "response", "result": result }),
|
|
517
|
+
Err((code, message)) => json!({
|
|
518
|
+
"v": 1, "id": id, "kind": "response",
|
|
519
|
+
"error": { "code": code, "message": message }
|
|
520
|
+
}),
|
|
521
|
+
};
|
|
522
|
+
deliver(&window, &resp);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
let resp = match handle(&method, ¶ms) {
|
|
526
|
+
Ok(result) => json!({ "v": 1, "id": id, "kind": "response", "result": result }),
|
|
527
|
+
Err((code, message)) => json!({
|
|
528
|
+
"v": 1, "id": id, "kind": "response",
|
|
529
|
+
"error": { "code": code, "message": message }
|
|
530
|
+
}),
|
|
531
|
+
};
|
|
532
|
+
deliver(&window, &resp);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// Error response envelope (same shape as the inline error json in appwrap_invoke).
|
|
537
|
+
fn deliver_error(window: &tauri::WebviewWindow, id: &str, code: &str, message: &str) {
|
|
538
|
+
let resp = json!({
|
|
539
|
+
"v": 1, "id": id, "kind": "response",
|
|
540
|
+
"error": { "code": code, "message": message }
|
|
541
|
+
});
|
|
542
|
+
deliver(window, &resp);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/// Route an incoming deep link — from the OS (`on_open_url`) OR a tapped local notification carrying
|
|
546
|
+
/// one (notifications_mac) — through the SAME warm/cold path: warm (post-handshake) → emit a
|
|
547
|
+
/// `deeplink.open` event; pre-handshake → stash the FIRST url as `pending` (drained into the handshake
|
|
548
|
+
/// response so the PWA routes before first paint). Reaches the main window via the stashed AppHandle,
|
|
549
|
+
/// so it's safe to call from any thread. DRY single source for both entry points.
|
|
550
|
+
pub fn route_deeplink(url: &str) {
|
|
551
|
+
let warm = {
|
|
552
|
+
let mut dl = DEEPLINK.lock().unwrap();
|
|
553
|
+
if !dl.handshaken && dl.pending.is_none() {
|
|
554
|
+
dl.pending = Some(url.to_string());
|
|
555
|
+
}
|
|
556
|
+
dl.handshaken
|
|
557
|
+
};
|
|
558
|
+
if warm {
|
|
559
|
+
if let Some(window) = APP.get().and_then(|app| app.get_webview_window("main")) {
|
|
560
|
+
let event = json!({ "v": 1, "kind": "event", "event": "deeplink.open",
|
|
561
|
+
"payload": { "url": url } });
|
|
562
|
+
deliver(&window, &event);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
fn deliver(window: &tauri::WebviewWindow, envelope: &Value) {
|
|
568
|
+
let js = format!(
|
|
569
|
+
"window.__appwrapDeliver({})",
|
|
570
|
+
serde_json::to_string(&envelope.to_string()).unwrap()
|
|
571
|
+
);
|
|
572
|
+
let _ = window.eval(&js);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
fn main() {
|
|
576
|
+
// No `.menu(...)` override: Tauri v2 installs the default macOS menu (App/Edit/Window),
|
|
577
|
+
// whose Edit item carries the Cut/Copy/Paste/Select-All shortcuts the WebView needs — so
|
|
578
|
+
// Cmd+C/V/X/A work in inputs with zero shell code. Suppressing it would break those bindings.
|
|
579
|
+
let mut builder = tauri::Builder::default()
|
|
580
|
+
.plugin(tauri_plugin_deep_link::init())
|
|
581
|
+
.plugin(tauri_plugin_notification::init())
|
|
582
|
+
// Launch-on-login. LaunchAgent (default) writes a per-user LaunchAgent plist — works for the
|
|
583
|
+
// adhoc-signed .app (no Developer ID needed). The setup hook below flips the item on/off to
|
|
584
|
+
// match the config so turning `autostart` off actually removes it.
|
|
585
|
+
.plugin(tauri_plugin_autostart::init(
|
|
586
|
+
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
|
587
|
+
None,
|
|
588
|
+
));
|
|
589
|
+
// Window size/position persistence across launches. The plugin auto-restores on window-ready
|
|
590
|
+
// and saves on close/move/resize; skipping registration (restoreWindowState:false) means the
|
|
591
|
+
// configured width/height always apply. Registered BEFORE the window is built so its
|
|
592
|
+
// on_window_ready restore hook fires for it.
|
|
593
|
+
if shell().restore_window_state {
|
|
594
|
+
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
|
595
|
+
}
|
|
596
|
+
builder
|
|
597
|
+
.invoke_handler(tauri::generate_handler![appwrap_invoke])
|
|
598
|
+
// Popups are plain NSWindows tauri doesn't know about; with the main (only tauri) window
|
|
599
|
+
// closed, tauri exits the app — but tear popups down explicitly so no dangling windows or
|
|
600
|
+
// live child webviews outlive the shell during teardown (or if the exit is ever prevented).
|
|
601
|
+
.on_window_event(|_window, _event| {
|
|
602
|
+
#[cfg(target_os = "macos")]
|
|
603
|
+
if _window.label() == "main"
|
|
604
|
+
&& matches!(_event, tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed)
|
|
605
|
+
{
|
|
606
|
+
popup_mac::close_all();
|
|
607
|
+
// Kill the handlers sidecar so the Bun process never outlives the shell.
|
|
608
|
+
sidecar::shutdown();
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
.setup(|app| {
|
|
612
|
+
// Stash the app handle so off-main-thread handlers can reach plugin state (notifications).
|
|
613
|
+
let _ = APP.set(app.handle().clone());
|
|
614
|
+
// Per-app custom handlers: spawn the Bun sidecar when configured (absolute path stamped by
|
|
615
|
+
// the CLI). No-op / degrades to inert when empty, on spawn failure, or on a missing `ready`.
|
|
616
|
+
// NOTE: init blocks setup for up to READY_TIMEOUT (5s, bounded) — intentional: sidecar
|
|
617
|
+
// methods must be claimable before the webview's first invoke can race them.
|
|
618
|
+
#[cfg(target_os = "macos")]
|
|
619
|
+
if !shell().handlers.is_empty() {
|
|
620
|
+
sidecar::init(&shell().handlers, &shell().handlers_runtime);
|
|
621
|
+
}
|
|
622
|
+
// Launch-on-login: reconcile the login item to the config every launch, so turning
|
|
623
|
+
// `autostart` off (or on) takes effect on the next run. Best-effort — a failure to
|
|
624
|
+
// enable/disable the LaunchAgent shouldn't abort startup.
|
|
625
|
+
{
|
|
626
|
+
use tauri_plugin_autostart::ManagerExt;
|
|
627
|
+
let al = app.autolaunch();
|
|
628
|
+
let r = if shell().autostart { al.enable() } else { al.disable() };
|
|
629
|
+
if let Err(e) = r {
|
|
630
|
+
eprintln!("[bridge] autostart reconcile ({}) failed: {e}", shell().autostart);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
let restore = shell().restore_window_state;
|
|
634
|
+
let win_builder = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
|
|
635
|
+
.title(shell().title.as_str())
|
|
636
|
+
.inner_size(shell().width, shell().height)
|
|
637
|
+
.initialization_script(INIT_JS);
|
|
638
|
+
// With state restore on, the plugin repositions/resizes AFTER creation — build hidden so
|
|
639
|
+
// the restore lands before first paint (no visible jump), then show below.
|
|
640
|
+
let win_builder = if restore { win_builder.visible(false) } else { win_builder };
|
|
641
|
+
// Safari-normalized UA must be set at BUILD time — the first document snapshots
|
|
642
|
+
// navigator.userAgent before setup() runs (probe: install()-time setCustomUserAgent
|
|
643
|
+
// only applied from the next navigation).
|
|
644
|
+
#[cfg(target_os = "macos")]
|
|
645
|
+
let win_builder = win_builder.user_agent(popup_mac::SAFARI_UA);
|
|
646
|
+
win_builder.build()?;
|
|
647
|
+
// Popup-window support (window.open → real child NSWindow; Firebase signInWithPopup)
|
|
648
|
+
// + Safari UA normalization. with_webview runs its closure on the main thread.
|
|
649
|
+
#[cfg(target_os = "macos")]
|
|
650
|
+
if let Some(window) = app.get_webview_window("main") {
|
|
651
|
+
let picker = shell().google_account_picker;
|
|
652
|
+
window.with_webview(move |w| popup_mac::install(w.inner() as _, picker))?;
|
|
653
|
+
}
|
|
654
|
+
// Reveal after the state plugin's on_window_ready restore has applied (built hidden above).
|
|
655
|
+
if restore {
|
|
656
|
+
if let Some(window) = app.get_webview_window("main") {
|
|
657
|
+
let _ = window.show();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// OS deep links (<urlScheme>://…, registered via the .app's CFBundleURLTypes). Warm →
|
|
661
|
+
// emit `deeplink.open` (same envelope as the internal loopback in appwrap_invoke);
|
|
662
|
+
// pre-handshake → stash the FIRST url, carried back in the handshake response.
|
|
663
|
+
// (oauth does NOT flow through here — ASWebAuthenticationSession captures its own callback.)
|
|
664
|
+
use tauri_plugin_deep_link::DeepLinkExt;
|
|
665
|
+
app.deep_link().on_open_url(move |event| {
|
|
666
|
+
for url in event.urls() {
|
|
667
|
+
let url = url.to_string();
|
|
668
|
+
eprintln!("[bridge] deeplink.open ← {url}");
|
|
669
|
+
// Warm-vs-cold decision + emit is shared with notification taps — see route_deeplink.
|
|
670
|
+
route_deeplink(&url);
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
// Install the UNUserNotificationCenter delegate now (main thread) so a notification tapped
|
|
674
|
+
// right after a cold launch is captured. Idempotent with the schedule-time call.
|
|
675
|
+
#[cfg(target_os = "macos")]
|
|
676
|
+
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
|
677
|
+
notifications_mac::ensure_delegate(mtm);
|
|
678
|
+
}
|
|
679
|
+
Ok(())
|
|
680
|
+
})
|
|
681
|
+
.run(tauri::generate_context!())
|
|
682
|
+
.expect("error while running appwrap desktop shell");
|
|
683
|
+
}
|