ruby_everywhere 0.0.1 → 0.1.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.
- checksums.yaml +4 -4
- data/exe/every +6 -0
- data/exe/rbe +6 -0
- data/lib/everywhere/boot.rb +138 -0
- data/lib/everywhere/cli.rb +44 -0
- data/lib/everywhere/commands/build.rb +267 -0
- data/lib/everywhere/commands/dev.rb +61 -0
- data/lib/everywhere/commands/doctor.rb +54 -0
- data/lib/everywhere/commands/install.rb +264 -0
- data/lib/everywhere/commands/platform/auth_status.rb +42 -0
- data/lib/everywhere/commands/platform/build.rb +181 -0
- data/lib/everywhere/commands/platform/login.rb +81 -0
- data/lib/everywhere/commands/platform/logout.rb +34 -0
- data/lib/everywhere/commands/platform/runner.rb +224 -0
- data/lib/everywhere/commands/release.rb +142 -0
- data/lib/everywhere/config.rb +188 -0
- data/lib/everywhere/database.rb +89 -0
- data/lib/everywhere/framework.rb +197 -0
- data/lib/everywhere/ignore.rb +85 -0
- data/lib/everywhere/javascript/bridge.js +174 -0
- data/lib/everywhere/paths.rb +35 -0
- data/lib/everywhere/platform/client.rb +125 -0
- data/lib/everywhere/platform/credentials.rb +71 -0
- data/lib/everywhere/platform/snapshot.rb +48 -0
- data/lib/everywhere/png.rb +110 -0
- data/lib/everywhere/receipt.rb +185 -0
- data/lib/everywhere/shellout.rb +46 -0
- data/lib/everywhere/ui.rb +37 -0
- data/lib/everywhere/version.rb +11 -0
- data/lib/everywhere.rb +50 -0
- data/lib/ruby_everywhere.rb +2 -3
- data/support/github/build.yml +85 -0
- data/support/macos/entitlements.plist +23 -0
- data/support/macos/notarize.sh +123 -0
- data/support/shell/splash/index.html +40 -0
- data/support/shell/src-tauri/Cargo.lock +5446 -0
- data/support/shell/src-tauri/Cargo.toml +19 -0
- data/support/shell/src-tauri/build.rs +3 -0
- data/support/shell/src-tauri/capabilities/default.json +22 -0
- data/support/shell/src-tauri/gen/schemas/acl-manifests.json +1 -0
- data/support/shell/src-tauri/gen/schemas/capabilities.json +1 -0
- data/support/shell/src-tauri/gen/schemas/desktop-schema.json +2634 -0
- data/support/shell/src-tauri/gen/schemas/macOS-schema.json +2634 -0
- data/support/shell/src-tauri/icons/icon.png +0 -0
- data/support/shell/src-tauri/src/main.rs +723 -0
- data/support/shell/src-tauri/tauri.conf.json +20 -0
- metadata +80 -7
- data/README.md +0 -11
|
@@ -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
|
+
}
|