ruby_everywhere 0.1.0 → 0.1.2

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.
@@ -0,0 +1,723 @@
1
+ // RubyEverywhere desktop shell.
2
+ //
3
+ // Boot sequence (packaged / repo-dev):
4
+ // 1. load app config (NATIVE_CONFIG env JSON, or Resources/everywhere.json in a .app)
5
+ // 2. pick a free localhost port
6
+ // 3. spawn the tebako-packaged Rails binary with NATIVE_PORT / NATIVE_APPDATA
7
+ // 4. show the splash page while polling until the server accepts connections
8
+ // 5. navigate the webview to http://127.0.0.1:{port}{entry_path}
9
+ // 6. kill the sidecar when the app exits
10
+ //
11
+ // Dev mode (`every dev`): NATIVE_DEV_URL set -> no sidecar, load that URL directly.
12
+
13
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
14
+
15
+ use std::net::{TcpListener, TcpStream};
16
+ use std::process::{Child, Command};
17
+ use std::sync::Mutex;
18
+ use std::time::{Duration, Instant};
19
+
20
+ use tauri::{Manager, RunEvent, WebviewUrl, WebviewWindowBuilder};
21
+
22
+ struct Sidecar(Mutex<Option<Child>>);
23
+
24
+ struct MenuEntry {
25
+ label: String,
26
+ path: String,
27
+ accelerator: Option<String>,
28
+ separator: bool,
29
+ }
30
+
31
+ struct AppConfig {
32
+ raw_json: String,
33
+ name: String,
34
+ bundle_id: Option<String>,
35
+ entry_path: String,
36
+ remote_url: Option<String>,
37
+ background_light: Option<(u8, u8, u8)>,
38
+ menu: Vec<MenuEntry>,
39
+ tray: Vec<TrayEntry>,
40
+ }
41
+
42
+ struct TrayEntry {
43
+ label: String,
44
+ path: Option<String>,
45
+ action: Option<String>,
46
+ separator: bool,
47
+ }
48
+
49
+ fn load_config() -> AppConfig {
50
+ let mut value: serde_json::Value = std::env::var("NATIVE_CONFIG")
51
+ .ok()
52
+ .and_then(|raw| serde_json::from_str(&raw).ok())
53
+ .or_else(|| {
54
+ let exe = std::env::current_exe().ok()?;
55
+ let path = exe.parent()?.join("../Resources/everywhere.json");
56
+ serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
57
+ })
58
+ .unwrap_or(serde_json::Value::Null);
59
+
60
+ // The shell knows the OS authoritatively — no user-agent guessing needed
61
+ // on desktop. ("macos" | "windows" | "linux")
62
+ if value.is_null() {
63
+ value = serde_json::json!({});
64
+ }
65
+ value["os"] = serde_json::json!(std::env::consts::OS);
66
+ if std::env::var("NATIVE_DEV_URL").is_ok() {
67
+ value["dev"] = serde_json::json!(true);
68
+ }
69
+
70
+ AppConfig {
71
+ raw_json: if value.is_null() { "{}".to_string() } else { value.to_string() },
72
+ name: value["name"].as_str().unwrap_or("RubyEverywhere App").to_string(),
73
+ bundle_id: value["bundle_id"].as_str().map(str::to_string),
74
+ entry_path: value["entry_path"].as_str().unwrap_or("/").to_string(),
75
+ remote_url: (value["mode"].as_str() == Some("remote"))
76
+ .then(|| value["remote_url"].as_str().map(str::to_string))
77
+ .flatten(),
78
+ background_light: value["background_color"]["light"].as_str().and_then(parse_hex),
79
+ menu: value["menu"]
80
+ .as_array()
81
+ .map(|items| {
82
+ items
83
+ .iter()
84
+ .map(|i| MenuEntry {
85
+ label: i["label"].as_str().unwrap_or_default().to_string(),
86
+ path: i["path"].as_str().unwrap_or_default().to_string(),
87
+ accelerator: i["accelerator"].as_str().map(str::to_string),
88
+ separator: i["separator"].as_bool().unwrap_or(false),
89
+ })
90
+ .collect()
91
+ })
92
+ .unwrap_or_default(),
93
+ tray: value["tray"]
94
+ .as_array()
95
+ .map(|items| {
96
+ items
97
+ .iter()
98
+ .map(|i| TrayEntry {
99
+ label: i["label"].as_str().unwrap_or_default().to_string(),
100
+ path: i["path"].as_str().map(str::to_string),
101
+ action: i["action"].as_str().map(str::to_string),
102
+ separator: i["separator"].as_bool().unwrap_or(false),
103
+ })
104
+ .collect()
105
+ })
106
+ .unwrap_or_default(),
107
+ }
108
+ }
109
+
110
+ fn parse_hex(hex: &str) -> Option<(u8, u8, u8)> {
111
+ let h = hex.trim_start_matches('#');
112
+ match h.len() {
113
+ 3 => {
114
+ let c = |i: usize| u8::from_str_radix(&h[i..=i], 16).map(|v| v * 17);
115
+ Some((c(0).ok()?, c(1).ok()?, c(2).ok()?))
116
+ }
117
+ 6 => {
118
+ let c = |i: usize| u8::from_str_radix(&h[i..i + 2], 16);
119
+ Some((c(0).ok()?, c(2).ok()?, c(4).ok()?))
120
+ }
121
+ _ => None,
122
+ }
123
+ }
124
+
125
+ fn free_port() -> u16 {
126
+ TcpListener::bind("127.0.0.1:0")
127
+ .expect("could not bind to a local port")
128
+ .local_addr()
129
+ .expect("no local addr")
130
+ .port()
131
+ }
132
+
133
+ fn main() {
134
+ let config = load_config();
135
+
136
+ tauri::Builder::default()
137
+ .plugin(tauri_plugin_notification::init())
138
+ .plugin(tauri_plugin_dialog::init())
139
+ .plugin(tauri_plugin_clipboard_manager::init())
140
+ .invoke_handler(tauri::generate_handler![notify_command])
141
+ .setup(move |app| {
142
+ // Bridge channel: pages emit events (the sanctioned webview->shell
143
+ // path for remote-origin content in Tauri v2; invoke is reserved
144
+ // for local content). Fire-and-forget, Strada-style.
145
+ {
146
+ use tauri::Listener;
147
+ let handle = app.handle().clone();
148
+ app.listen_any("everywhere:notify", move |event| {
149
+ let v: serde_json::Value =
150
+ serde_json::from_str(event.payload()).unwrap_or(serde_json::Value::Null);
151
+ notify(
152
+ &handle,
153
+ v["title"].as_str().unwrap_or("Notification"),
154
+ v["body"].as_str().unwrap_or_default(),
155
+ );
156
+ });
157
+ }
158
+ build_menu(app, &config)?;
159
+
160
+ // Dev mode (`every dev`): no sidecar, just point the webview at the
161
+ // developer's normal dev server.
162
+ if let Ok(dev_url) = std::env::var("NATIVE_DEV_URL") {
163
+ build_window(
164
+ app,
165
+ &config,
166
+ &format!("{} (dev)", config.name),
167
+ WebviewUrl::External(dev_url.parse().expect("invalid NATIVE_DEV_URL")),
168
+ )?;
169
+ return Ok(());
170
+ }
171
+
172
+ // Test mode: reproduce the packaged splash->navigate flow against an
173
+ // arbitrary URL (usually the dev server) without a sidecar.
174
+ if let Ok(nav_url) = std::env::var("NATIVE_NAVIGATE_URL") {
175
+ let window = build_window(
176
+ app,
177
+ &config,
178
+ &format!("{} (navtest)", config.name),
179
+ WebviewUrl::App("index.html".into()),
180
+ )?;
181
+ std::thread::spawn(move || {
182
+ std::thread::sleep(Duration::from_millis(1500));
183
+ if let Err(e) = window.navigate(nav_url.parse().unwrap()) {
184
+ eprintln!("navigate failed: {e}");
185
+ }
186
+ });
187
+ return Ok(());
188
+ }
189
+
190
+ // Remote mode: no sidecar — the app is already deployed somewhere.
191
+ // The webview is a branded window onto it; native hooks still work
192
+ // because the bridge rides the webview, not localhost.
193
+ if let Some(remote_url) = &config.remote_url {
194
+ // Capabilities are compile-time static and scoped to localhost
195
+ // (capabilities/default.json), but the remote origin is per-app
196
+ // (everywhere.yml). Without authorizing it, every page->shell
197
+ // command (setTitle, event.listen, dialog.ask, notification)
198
+ // is rejected "not allowed by ACL". Add a capability at runtime
199
+ // (dynamic-acl) granting the remote origin the same IPC the
200
+ // local app gets.
201
+ authorize_remote_origin(app, remote_url)?;
202
+
203
+ let url = format!("{}{}", remote_url.trim_end_matches('/'), config.entry_path);
204
+ build_window(
205
+ app,
206
+ &config,
207
+ &config.name,
208
+ WebviewUrl::External(url.parse().expect("invalid remote url")),
209
+ )?;
210
+ return Ok(());
211
+ }
212
+
213
+ let port = free_port();
214
+
215
+ // Resolution order: explicit env override (repo dev) -> "app-server"
216
+ // next to this executable (inside a bundled .app) -> repo default.
217
+ let sidecar_path = std::env::var("NATIVE_SIDECAR_PATH")
218
+ .ok()
219
+ .or_else(|| {
220
+ let exe = std::env::current_exe().ok()?;
221
+ let candidate = exe.parent()?.join("app-server");
222
+ candidate
223
+ .exists()
224
+ .then(|| candidate.to_string_lossy().into_owned())
225
+ })
226
+ .unwrap_or_else(|| "../dist/example".to_string());
227
+
228
+ // App data lives at <platform data dir>/<bundle_id> so the location
229
+ // is governed by config/everywhere.yml, not the compile-time
230
+ // tauri.conf identifier.
231
+ let app_data = match &config.bundle_id {
232
+ Some(id) => app.path().data_dir()?.join(id),
233
+ None => app.path().app_data_dir()?,
234
+ };
235
+ std::fs::create_dir_all(&app_data)?;
236
+
237
+ let mut sidecar_cmd = Command::new(&sidecar_path);
238
+ sidecar_cmd
239
+ .env("NATIVE_PORT", port.to_string())
240
+ .env("NATIVE_APPDATA", &app_data)
241
+ // The sidecar watches this pid and shuts itself down if the
242
+ // shell dies without running cleanup (crash, SIGKILL, pkill).
243
+ .env("NATIVE_SHELL_PID", std::process::id().to_string());
244
+ // Own process group so exit cleanup can reap the WHOLE tree —
245
+ // Solid Queue forks workers that would otherwise outlive Puma.
246
+ #[cfg(unix)]
247
+ {
248
+ use std::os::unix::process::CommandExt;
249
+ sidecar_cmd.process_group(0);
250
+ }
251
+ let child = sidecar_cmd
252
+ .spawn()
253
+ .unwrap_or_else(|e| panic!("failed to spawn sidecar {sidecar_path}: {e}"));
254
+ app.manage(Sidecar(Mutex::new(Some(child))));
255
+
256
+ let window = build_window(
257
+ app,
258
+ &config,
259
+ &config.name,
260
+ custom_splash_url().unwrap_or_else(|| WebviewUrl::App("index.html".into())),
261
+ )?;
262
+
263
+ // Wait for the server, then swap the splash for the real app.
264
+ let entry_path = config.entry_path.clone();
265
+ std::thread::spawn(move || {
266
+ let addr = format!("127.0.0.1:{port}");
267
+ let started = Instant::now();
268
+ loop {
269
+ if TcpStream::connect_timeout(
270
+ &addr.parse().unwrap(),
271
+ Duration::from_millis(200),
272
+ )
273
+ .is_ok()
274
+ {
275
+ break;
276
+ }
277
+ if started.elapsed() > Duration::from_secs(60) {
278
+ eprintln!("sidecar never came up on {addr}");
279
+ return;
280
+ }
281
+ std::thread::sleep(Duration::from_millis(150));
282
+ }
283
+ println!("app up on {addr} after {:?}", started.elapsed());
284
+ let url: tauri::Url = format!("http://{addr}{entry_path}").parse().unwrap();
285
+ if let Err(e) = window.navigate(url) {
286
+ eprintln!("navigate failed: {e}");
287
+ }
288
+ });
289
+
290
+ Ok(())
291
+ })
292
+ .build(tauri::generate_context!())
293
+ .expect("error while building tauri application")
294
+ .run(|app, event| {
295
+ if let RunEvent::Exit = event {
296
+ // No sidecar state in dev mode (NATIVE_DEV_URL).
297
+ if let Some(sidecar) = app.try_state::<Sidecar>() {
298
+ if let Some(mut child) = sidecar.0.lock().unwrap().take() {
299
+ #[cfg(unix)]
300
+ {
301
+ // TERM the process group (graceful Puma stop shuts
302
+ // down Solid Queue too), then KILL any stragglers.
303
+ let group = format!("-{}", child.id());
304
+ let _ = Command::new("kill").args(["-TERM", &group]).status();
305
+ for _ in 0..50 {
306
+ if let Ok(Some(_)) = child.try_wait() {
307
+ break;
308
+ }
309
+ std::thread::sleep(Duration::from_millis(100));
310
+ }
311
+ let _ = Command::new("kill").args(["-KILL", &group]).status();
312
+ }
313
+ #[cfg(not(unix))]
314
+ let _ = child.kill();
315
+ let _ = child.wait();
316
+ }
317
+ }
318
+ }
319
+ });
320
+ }
321
+
322
+ // Native menu bar: standard App/Edit/Window menus (Edit is what makes
323
+ // Cmd+C/V/X/Z work in a webview app on macOS) plus config-driven items in the
324
+ // app menu. Custom item clicks emit "everywhere:menu" {path} to the page,
325
+ // where the injected listener hands them to Turbo.
326
+ fn build_menu(app: &tauri::App, config: &AppConfig) -> tauri::Result<()> {
327
+ use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
328
+
329
+ let about = tauri::menu::AboutMetadataBuilder::new()
330
+ .name(Some(config.name.clone()))
331
+ .build();
332
+ let mut app_menu = SubmenuBuilder::new(app, &config.name).about(Some(about));
333
+ if !config.menu.is_empty() {
334
+ app_menu = app_menu.separator();
335
+ for entry in &config.menu {
336
+ if entry.separator {
337
+ app_menu = app_menu.separator();
338
+ continue;
339
+ }
340
+ let mut item = MenuItemBuilder::with_id(format!("nav:{}", entry.path), &entry.label);
341
+ if let Some(accel) = &entry.accelerator {
342
+ item = item.accelerator(accel);
343
+ }
344
+ app_menu = app_menu.item(&item.build(app)?);
345
+ }
346
+ }
347
+ let app_menu = app_menu
348
+ .separator()
349
+ .hide()
350
+ .hide_others()
351
+ .show_all()
352
+ .separator()
353
+ .quit()
354
+ .build()?;
355
+
356
+ let edit = SubmenuBuilder::new(app, "Edit")
357
+ .undo()
358
+ .redo()
359
+ .separator()
360
+ .cut()
361
+ .copy()
362
+ .paste()
363
+ .separator()
364
+ .select_all()
365
+ .build()?;
366
+
367
+ let view = SubmenuBuilder::new(app, "View")
368
+ .item(
369
+ &MenuItemBuilder::with_id("reload", "Reload")
370
+ .accelerator("CmdOrCtrl+R")
371
+ .build(app)?,
372
+ )
373
+ .item(
374
+ &MenuItemBuilder::with_id("devtools", "Developer Tools")
375
+ .accelerator("CmdOrCtrl+Alt+I")
376
+ .build(app)?,
377
+ )
378
+ .separator()
379
+ .fullscreen()
380
+ .build()?;
381
+
382
+ let window_menu = SubmenuBuilder::new(app, "Window")
383
+ .minimize()
384
+ .maximize()
385
+ .separator()
386
+ .close_window()
387
+ .build()?;
388
+
389
+ let menu = MenuBuilder::new(app)
390
+ .items(&[&app_menu, &edit, &view, &window_menu])
391
+ .build()?;
392
+ app.set_menu(menu)?;
393
+
394
+ app.on_menu_event(|app, event| handle_menu_id(app, event.id().0.as_str()));
395
+
396
+ build_tray(app, config)?;
397
+
398
+ Ok(())
399
+ }
400
+
401
+ // Shared by the app menu and the tray.
402
+ fn handle_menu_id(app: &tauri::AppHandle, id: &str) {
403
+ use tauri::Emitter;
404
+ match id {
405
+ "quit" => app.exit(0),
406
+ "show" => focus_main(app),
407
+ "reload" => {
408
+ if let Some(w) = app.get_webview_window("main") {
409
+ let _ = w.eval("location.reload()");
410
+ }
411
+ }
412
+ "devtools" => {
413
+ if let Some(w) = app.get_webview_window("main") {
414
+ w.open_devtools();
415
+ }
416
+ }
417
+ _ => {
418
+ if let Some(path) = id.strip_prefix("nav:") {
419
+ println!("[bridge] menu -> {path}");
420
+ focus_main(app);
421
+ let _ = app.emit_to("main", "everywhere:menu", serde_json::json!({ "path": path }));
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ fn focus_main(app: &tauri::AppHandle) {
428
+ if let Some(w) = app.get_webview_window("main") {
429
+ let _ = w.show();
430
+ let _ = w.set_focus();
431
+ }
432
+ }
433
+
434
+ // System tray from `tray:` in everywhere.yml. Items share the nav/quit/show
435
+ // handling with the app menu.
436
+ fn build_tray(app: &tauri::App, config: &AppConfig) -> tauri::Result<()> {
437
+ if config.tray.is_empty() {
438
+ return Ok(());
439
+ }
440
+ use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem};
441
+ use tauri::tray::TrayIconBuilder;
442
+
443
+ let mut menu = MenuBuilder::new(app);
444
+ for entry in &config.tray {
445
+ if entry.separator {
446
+ menu = menu.item(&PredefinedMenuItem::separator(app)?);
447
+ continue;
448
+ }
449
+ let id = match (&entry.action, &entry.path) {
450
+ (Some(action), _) => action.clone(),
451
+ (None, Some(path)) => format!("nav:{path}"),
452
+ _ => continue,
453
+ };
454
+ menu = menu.item(&MenuItemBuilder::with_id(id, &entry.label).build(app)?);
455
+ }
456
+ let menu = menu.build()?;
457
+
458
+ let mut tray = TrayIconBuilder::with_id("main")
459
+ .menu(&menu)
460
+ .show_menu_on_left_click(true)
461
+ .tooltip(&config.name);
462
+ if let Some(icon) = app.default_window_icon() {
463
+ tray = tray.icon(icon.clone());
464
+ }
465
+ tray.on_menu_event(|app, event| handle_menu_id(app, event.id().0.as_str()))
466
+ .build(app)?;
467
+
468
+ Ok(())
469
+ }
470
+
471
+ // The page-side half of the shell, injected into every page (app pages, the
472
+ // splash, remote sites). The config JSON is substituted in at window build.
473
+ const INIT_SCRIPT: &str = r#"
474
+ (function() {
475
+ window.__EVERYWHERE_CONFIG__ = /*__CONFIG_JSON__*/ null || {};
476
+ const cfg = window.__EVERYWHERE_CONFIG__;
477
+
478
+ const report = (kind, msg) => {
479
+ try { fetch('/?' + kind + '=' + encodeURIComponent(msg)) } catch (_) {}
480
+ };
481
+ window.addEventListener('error', e =>
482
+ report('jserror', e.message + ' @ ' + (e.filename || '?') + ':' + (e.lineno || '?')));
483
+ window.addEventListener('unhandledrejection', e =>
484
+ report('jsreject', String(e.reason)));
485
+ document.addEventListener('DOMContentLoaded', () =>
486
+ report('jsboot', 'tauri=' + !!window.__TAURI__));
487
+
488
+ // WKWebView has no native window.confirm — Turbo's data-turbo-confirm would
489
+ // silently cancel. Install the shell's native dialog as the DEFAULT confirm
490
+ // at the exact moment Turbo lands on window (before any app code can run),
491
+ // so an app that sets its own custom confirm afterwards always wins.
492
+ (function() {
493
+ const nativeConfirm = (message) =>
494
+ window.__TAURI__ && window.__TAURI__.dialog
495
+ ? window.__TAURI__.dialog.ask(message, { title: cfg.name || 'Confirm', kind: 'warning' })
496
+ : Promise.resolve(false);
497
+ nativeConfirm.everywhereDefault = true;
498
+
499
+ const install = (t) => {
500
+ try {
501
+ if (!t) return;
502
+ if (t.config && t.config.forms) { t.config.forms.confirm = nativeConfirm; }
503
+ else if (t.setConfirmMethod) { t.setConfirmMethod(nativeConfirm); }
504
+ } catch (_) {}
505
+ };
506
+
507
+ let turbo = window.Turbo;
508
+ if (turbo) { install(turbo); }
509
+ try {
510
+ Object.defineProperty(window, 'Turbo', {
511
+ configurable: true,
512
+ get() { return turbo; },
513
+ set(v) { turbo = v; install(v); }
514
+ });
515
+ } catch (_) {}
516
+ })();
517
+
518
+ // Window title follows the page title (apps own their <title> tags);
519
+ // fall back to the app name, suffix "(dev)" in dev mode.
520
+ const syncTitle = () => {
521
+ try {
522
+ const base = document.title || cfg.name || "";
523
+ const title = cfg.dev ? base + " (dev)" : base;
524
+ if (title && window.__TAURI__ && window.__TAURI__.window) {
525
+ window.__TAURI__.window.getCurrentWindow().setTitle(title);
526
+ }
527
+ } catch (_) {}
528
+ };
529
+ document.addEventListener('DOMContentLoaded', () => {
530
+ syncTitle();
531
+ const el = document.querySelector('title');
532
+ if (el) new MutationObserver(syncTitle).observe(el, { childList: true });
533
+ });
534
+ document.addEventListener('turbo:load', syncTitle);
535
+
536
+ const hookMenu = () => {
537
+ if (window.__TAURI__ && window.__TAURI__.event) {
538
+ window.__TAURI__.event.listen('everywhere:menu', (e) => {
539
+ const path = e.payload && e.payload.path;
540
+ if (!path) return;
541
+ if (window.Turbo) { window.Turbo.visit(path); }
542
+ else { window.location.assign(path); }
543
+ });
544
+ } else {
545
+ setTimeout(hookMenu, 100);
546
+ }
547
+ };
548
+ hookMenu();
549
+ })();
550
+ "#;
551
+
552
+ fn build_window(
553
+ app: &tauri::App,
554
+ config: &AppConfig,
555
+ title: &str,
556
+ url: WebviewUrl,
557
+ ) -> tauri::Result<tauri::WebviewWindow> {
558
+ let mut builder = WebviewWindowBuilder::new(app, "main", url)
559
+ .title(title)
560
+ .inner_size(1100.0, 750.0)
561
+ // Injected into every page: app config global, remote console, native
562
+ // confirm default, and the menu-navigation listener.
563
+ .initialization_script(INIT_SCRIPT.replace("/*__CONFIG_JSON__*/ null", &config.raw_json));
564
+ if let Some((r, g, b)) = config.background_light {
565
+ builder = builder.background_color(tauri::window::Color(r, g, b, 255));
566
+ }
567
+
568
+ // Cookie / session persistence: give the webview a stable data store so
569
+ // logins survive relaunches. macOS wants a 16-byte identifier (derived from
570
+ // bundle_id); Windows/Linux want an explicit directory. Dev mode stays
571
+ // ephemeral — a persistent cache would keep serving stale dev assets.
572
+ let dev_mode = std::env::var("NATIVE_DEV_URL").is_ok();
573
+ if !dev_mode {
574
+ #[cfg(target_os = "macos")]
575
+ {
576
+ builder = builder.data_store_identifier(data_store_id(
577
+ config.bundle_id.as_deref().unwrap_or("com.rubyeverywhere.app"),
578
+ ));
579
+ }
580
+ #[cfg(any(windows, target_os = "linux"))]
581
+ {
582
+ if let Ok(dir) = app.path().data_dir() {
583
+ let id = config.bundle_id.clone().unwrap_or("com.rubyeverywhere.app".into());
584
+ builder = builder.data_directory(dir.join(id).join("webview"));
585
+ }
586
+ }
587
+ }
588
+
589
+ // External links open in the system browser; only the app's own origins
590
+ // navigate inside the window.
591
+ let remote_host = config
592
+ .remote_url
593
+ .as_deref()
594
+ .and_then(|u| u.parse::<tauri::Url>().ok())
595
+ .and_then(|u| u.host_str().map(str::to_string));
596
+ builder = builder.on_navigation(move |url| {
597
+ if is_internal(url, remote_host.as_deref()) {
598
+ true
599
+ } else {
600
+ println!("external link -> system browser: {url}");
601
+ open_external(url.as_str());
602
+ false
603
+ }
604
+ });
605
+
606
+ builder.build()
607
+ }
608
+
609
+ // An app-provided splash (Resources/splash.html, from `splash:` in
610
+ // everywhere.yml) loads via data: URL — no server needed before Rails boots.
611
+ fn custom_splash_url() -> Option<WebviewUrl> {
612
+ let exe = std::env::current_exe().ok()?;
613
+ let html = std::fs::read(exe.parent()?.join("../Resources/splash.html")).ok()?;
614
+ format!("data:text/html;base64,{}", base64(&html)).parse().ok().map(WebviewUrl::External)
615
+ }
616
+
617
+ fn base64(data: &[u8]) -> String {
618
+ const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
619
+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
620
+ for chunk in data.chunks(3) {
621
+ let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
622
+ let n = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]);
623
+ out.push(TABLE[(n >> 18) as usize & 63] as char);
624
+ out.push(TABLE[(n >> 12) as usize & 63] as char);
625
+ out.push(if chunk.len() > 1 { TABLE[(n >> 6) as usize & 63] as char } else { '=' });
626
+ out.push(if chunk.len() > 2 { TABLE[n as usize & 63] as char } else { '=' });
627
+ }
628
+ out
629
+ }
630
+
631
+ // Grant the configured remote origin the same IPC surface the packaged local
632
+ // app gets from capabilities/default.json. The permission set is mirrored
633
+ // deliberately: whatever the local app can do, the remote app can do — no more.
634
+ // `local: false` keeps it scoped to the remote URL pattern only. The urlpattern
635
+ // treats an origin with no path as matching every path under it.
636
+ fn authorize_remote_origin(app: &tauri::App, remote_url: &str) -> tauri::Result<()> {
637
+ let Ok(origin) = remote_url.parse::<tauri::Url>() else {
638
+ eprintln!("[bridge] remote url {remote_url} not a valid URL; skipping ACL grant");
639
+ return Ok(());
640
+ };
641
+ let Some(host) = origin.host_str() else {
642
+ return Ok(());
643
+ };
644
+ let pattern = match origin.port() {
645
+ Some(port) => format!("{}://{}:{}", origin.scheme(), host, port),
646
+ None => format!("{}://{}", origin.scheme(), host),
647
+ };
648
+
649
+ let capability = serde_json::json!({
650
+ "identifier": "remote-app",
651
+ "description": "IPC for the configured remote origin (remote-mode app)",
652
+ "windows": ["main"],
653
+ "local": false,
654
+ "remote": { "urls": [pattern] },
655
+ "permissions": [
656
+ "core:default",
657
+ "core:window:allow-set-title",
658
+ "notification:default",
659
+ "dialog:default",
660
+ "clipboard-manager:allow-read-text",
661
+ "clipboard-manager:allow-write-text"
662
+ ]
663
+ });
664
+
665
+ app.add_capability(capability.to_string())
666
+ }
667
+
668
+ fn is_internal(url: &tauri::Url, remote_host: Option<&str>) -> bool {
669
+ match url.scheme() {
670
+ // splash / injected pages / blank interstitials
671
+ "tauri" | "app" | "about" | "data" | "blob" => true,
672
+ "http" | "https" => {
673
+ let host = url.host_str().unwrap_or("");
674
+ host == "127.0.0.1" || host == "localhost" || Some(host) == remote_host
675
+ }
676
+ _ => false,
677
+ }
678
+ }
679
+
680
+ fn open_external(url: &str) {
681
+ #[cfg(target_os = "macos")]
682
+ let cmd = ("open", vec![url]);
683
+ #[cfg(target_os = "linux")]
684
+ let cmd = ("xdg-open", vec![url]);
685
+ #[cfg(windows)]
686
+ let cmd = ("cmd", vec!["/c", "start", "", url]);
687
+ let _ = Command::new(cmd.0).args(cmd.1).spawn();
688
+ }
689
+
690
+ // Stable 16-byte webview data-store id derived from the bundle_id.
691
+ #[cfg(target_os = "macos")]
692
+ fn data_store_id(bundle_id: &str) -> [u8; 16] {
693
+ use std::hash::{Hash, Hasher};
694
+ let mut out = [0u8; 16];
695
+ for (i, chunk) in out.chunks_mut(8).enumerate() {
696
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
697
+ (bundle_id, i as u64).hash(&mut hasher);
698
+ chunk.copy_from_slice(&hasher.finish().to_le_bytes());
699
+ }
700
+ out
701
+ }
702
+
703
+ // First bridge command: fired from the webview (Stimulus) via window.__TAURI__.
704
+ // The notification plugin is the real path (proper Notification Center delivery,
705
+ // attributed to the app). osascript remains only as a dev fallback — bare
706
+ // `cargo run` binaries can't always talk to the notification APIs.
707
+ fn notify(app: &tauri::AppHandle, title: &str, body: &str) {
708
+ use tauri_plugin_notification::NotificationExt;
709
+ println!("[bridge] notify: {title} — {body}");
710
+ if let Err(e) = app.notification().builder().title(title).body(body).show() {
711
+ eprintln!("[bridge] notification plugin failed ({e}); osascript fallback");
712
+ #[cfg(target_os = "macos")]
713
+ {
714
+ let script = format!("display notification {body:?} with title {title:?}");
715
+ let _ = Command::new("osascript").arg("-e").arg(script).spawn();
716
+ }
717
+ }
718
+ }
719
+
720
+ #[tauri::command]
721
+ fn notify_command(app: tauri::AppHandle, title: String, body: String) {
722
+ notify(&app, &title, &body);
723
+ }