Skip to content
384 linesCodeBlameRaw
1//! What the site may ask of the app: the commands behind `window.g1tDesktop`
2//! (bridge.js), and the launcher's own.
3//!
4//! Every command checks who is asking. The site's commands answer only a
5//! page of the configured server; the launcher's only the page bundled
6//! with the app. A page from anywhere else gets nothing, whatever it
7//! loads.
8
9use serde::Serialize;
10use tauri::{AppHandle, Manager, UserAttentionType, Webview};
11use tauri_plugin_notification::NotificationExt;
12use tauri_plugin_opener::OpenerExt;
13use url::Url;
14
15use crate::settings::{self, Settings};
16use crate::{badge, config, devtools, frame, runner, updates};
17
18const SCRIPT: &str = include_str!("bridge.js");
19
20/// The bridge, run at the start of every page the site's web view loads:
21/// `custom` is whether g1t's title bar is above it (frame.rs).
22pub fn script(custom: bool) -> String {
23 SCRIPT
24 .replace("__FRAME__", if custom { "custom" } else { "system" })
25 .replace("__VERSION__", env!("CARGO_PKG_VERSION"))
26 .replace("__PLATFORM__", std::env::consts::OS)
27 .replace("\"__OUTSIDE__\"", &serde_json::to_string(crate::navigation::OUTSIDE).expect("a list of paths is JSON"))
28}
29
30/// What the site and the launcher learn about the app.
31#[derive(Serialize)]
32pub struct Info {
33 pub version: String,
34 pub platform: String,
35 pub server: String,
36 /// The version downloaded and waiting for a restart, if one is.
37 pub update_ready: Option<String>,
38}
39
40fn caller(webview: &Webview) -> Result<Url, String> {
41 webview.url().map_err(|error| error.to_string())
42}
43
44/// The page asking is one of the server's.
45fn from_site(app: &AppHandle, webview: &Webview) -> Result<(), String> {
46 let url = caller(webview)?;
47 if config::is_server(app, &url) { Ok(()) } else { Err("only g1t's own pages may ask that".into()) }
48}
49
50/// The page asking is the server's, in the main window under the title
51/// bar: the one that draws the settings dialog and the runner menu.
52fn from_site_page(app: &AppHandle, webview: &Webview) -> Result<(), String> {
53 from_site(app, webview)?;
54 if webview.label() == frame::SITE { Ok(()) } else { Err("only g1t's page in the window may ask that".into()) }
55}
56
57/// The page asking is the launcher.
58fn from_launcher(webview: &Webview) -> Result<(), String> {
59 let url = caller(webview)?;
60 if config::is_launcher(&url) { Ok(()) } else { Err("only the launcher may ask that".into()) }
61}
62
63/// The page asking is the settings window's, bundled with the app.
64fn from_settings(webview: &Webview) -> Result<(), String> {
65 from_launcher(webview)?;
66 if webview.label() == settings::SETTINGS { Ok(()) } else { Err("only the settings window may ask that".into()) }
67}
68
69/// The Runner window, a page bundled with the app (launcher/runner.html).
70fn from_runner(webview: &Webview) -> Result<(), String> {
71 from_launcher(webview)?;
72 if webview.label() == settings::RUNNER { Ok(()) } else { Err("only the Runner window may ask that".into()) }
73}
74
75/// The settings window, or the Runner window, which keeps the runner's settings.
76fn from_settings_or_runner(webview: &Webview) -> Result<(), String> {
77 from_settings(webview).or_else(|_| from_runner(webview))
78}
79
80fn from_either(app: &AppHandle, webview: &Webview) -> Result<(), String> {
81 from_site(app, webview).or_else(|_| from_launcher(webview))
82}
83
84/// The page asking is the one under the title bar, whichever it is: the
85/// site's, the launcher, or GitHub's while signing in.
86fn from_page(app: &AppHandle, webview: &Webview) -> Result<(), String> {
87 let url = caller(webview)?;
88 let shown = config::is_server(app, &url) || config::is_launcher(&url) || crate::navigation::signing_in(&url);
89 if webview.label() == frame::SITE && shown { Ok(()) } else { Err("only the page in the window may ask that".into()) }
90}
91
92/// The page asking is the title bar's, bundled with the app.
93fn from_bar(webview: &Webview) -> Result<(), String> {
94 from_launcher(webview)?;
95 if webview.label() == frame::BAR { Ok(()) } else { Err("only the title bar may ask that".into()) }
96}
97
98/// The page asking has window buttons: the title bar, or the settings or Runner window's own.
99fn from_frame(webview: &Webview) -> Result<(), String> {
100 from_bar(webview).or_else(|_| from_settings_or_runner(webview))
101}
102
103#[tauri::command]
104pub fn desktop_info(app: AppHandle, webview: Webview) -> Result<Info, String> {
105 from_either(&app, &webview)?;
106 Ok(Info {
107 version: env!("CARGO_PKG_VERSION").into(),
108 platform: std::env::consts::OS.into(),
109 server: config::server(&app).to_string(),
110 update_ready: updates::ready(&app),
111 })
112}
113
114/// A notification from the system: the site asks for one when a message
115/// is for the person and the window is behind another.
116#[tauri::command]
117pub fn notify(app: AppHandle, webview: Webview, title: String, body: String) -> Result<(), String> {
118 from_site(&app, &webview)?;
119 let settings = config::settings(&app);
120 if let Some(window) = crate::frame::window(&app).filter(|window| settings.flash && !window.is_focused().unwrap_or(false)) {
121 let _ = window.request_user_attention(Some(UserAttentionType::Informational));
122 }
123 if !settings.notifications {
124 return Ok(());
125 }
126 app.notification().builder().title(title).body(body).show().map_err(|error| error.to_string())
127}
128
129/// What needs the person, on the icon and the tray; 0 clears it.
130#[tauri::command]
131pub fn set_badge(app: AppHandle, webview: Webview, count: i64) -> Result<(), String> {
132 from_site(&app, &webview)?;
133 badge::set(&app, count.max(0));
134 Ok(())
135}
136
137/// A link to somewhere else, opened in the browser the system uses.
138#[tauri::command]
139pub fn open_url(app: AppHandle, webview: Webview, url: String) -> Result<(), String> {
140 from_either(&app, &webview)?;
141 let parsed = Url::parse(&url).map_err(|error| error.to_string())?;
142 if !matches!(parsed.scheme(), "http" | "https" | "mailto") {
143 return Err(format!("{} links stay in the app", parsed.scheme()));
144 }
145 app.opener().open_url(parsed.as_str(), None::<&str>).map_err(|error| error.to_string())
146}
147
148/// Whether a server answers at all: the launcher asks before the window
149/// goes there, so a machine with no network sees a page of ours, not the
150/// web view's own error.
151#[tauri::command]
152pub async fn reachable(app: AppHandle, webview: Webview, url: Option<String>) -> Result<bool, String> {
153 from_launcher(&webview)?;
154 let target = match url {
155 Some(text) => config::parse(&text).ok_or("That is not a web address.")?,
156 None => config::server(&app),
157 };
158 let client = reqwest::Client::builder()
159 .timeout(std::time::Duration::from_secs(6))
160 .build()
161 .map_err(|error| error.to_string())?;
162 Ok(client.head(target).send().await.is_ok())
163}
164
165/// The launcher chose a server: it is kept, and the window goes there next.
166#[tauri::command]
167pub fn set_server(app: AppHandle, webview: Webview, url: String) -> Result<String, String> {
168 from_launcher(&webview)?;
169 config::set_server(&app, &url).map(|server| server.to_string())
170}
171
172/// Looks for an update now: `current`, `ready:<version>`, or an error.
173/// The settings, in their window or the dialog over the site (`shown`),
174/// show the answer themselves; anyone else asking is also told with a
175/// notification.
176#[tauri::command]
177pub async fn check_for_updates(app: AppHandle, webview: Webview, shown: Option<bool>) -> Result<String, String> {
178 from_either(&app, &webview)?;
179 let shown = webview.label() == settings::SETTINGS || shown == Some(true);
180 updates::check(&app, !shown).await
181}
182
183/// A title bar button: `minimize`, `maximize` (which toggles), `close`,
184/// `drag`, or `maximized` to ask. Answers whether the window is maximized.
185#[tauri::command]
186pub fn window_control(app: AppHandle, webview: Webview, action: String) -> Result<bool, String> {
187 from_frame(&webview)?;
188 crate::frame::control(&app, &webview.window(), &action)
189}
190
191/// The title bar's menu button: the app's menu, at that place in the window.
192#[tauri::command]
193pub fn window_menu(webview: Webview, x: f64, y: f64) -> Result<(), String> {
194 from_bar(&webview)?;
195 crate::frame::menu(&webview.window(), x, y)
196}
197
198/// The page under the title bar says what it is: its title, its search,
199/// whether there is a way back, its colours (frame.rs).
200#[tauri::command]
201pub fn title_bar_page(app: AppHandle, webview: Webview, page: frame::Page) -> Result<(), String> {
202 from_page(&app, &webview)?;
203 frame::page(&app, page);
204 Ok(())
205}
206
207/// What the title bar shows now: it asks as it loads.
208#[tauri::command]
209pub fn title_bar_current(app: AppHandle, webview: Webview) -> Result<frame::Page, String> {
210 from_bar(&webview)?;
211 Ok(frame::current(&app))
212}
213
214/// A title bar button that acts on the page: `back`, `forward`, `search`,
215/// `focus`, `settings`, or `runner`, with `at` where its dot is across the
216/// bar, as a share of the bar's width.
217#[tauri::command]
218pub fn title_bar_act(app: AppHandle, webview: Webview, action: String, at: Option<f64>) -> Result<(), String> {
219 from_bar(&webview)?;
220 frame::act(&app, &action, at)
221}
222
223/// Restarts g1t, installing a downloaded update on the way: the settings
224/// window offers it after a change that needs one.
225#[tauri::command]
226pub fn restart(app: AppHandle, webview: Webview) -> Result<(), String> {
227 from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
228 if updates::ready(&app).is_some() {
229 updates::restart(&app);
230 }
231 runner::shutdown(&app);
232 app.restart()
233}
234
235/// The settings window's "Restart now": installs the downloaded update and starts it.
236#[tauri::command]
237pub fn restart_to_update(app: AppHandle, webview: Webview) -> Result<(), String> {
238 from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
239 updates::restart(&app);
240 Ok(())
241}
242
243/// Opens the app's settings: the site's account menu asks for it. They
244/// open over the site, or in their own window when it can't draw them.
245#[tauri::command]
246pub fn open_settings(app: AppHandle, webview: Webview) -> Result<(), String> {
247 from_either(&app, &webview)?;
248 settings::open(&app);
249 Ok(())
250}
251
252/// The site's page says it draws the settings dialog and the runner menu
253/// itself, once it can (true), or no longer (false). Until it says so, each
254/// page loaded in the window has them open in windows of their own.
255#[tauri::command]
256pub fn site_ready(app: AppHandle, webview: Webview, ready: bool) -> Result<(), String> {
257 from_site_page(&app, &webview)?;
258 config::set_site_ready(&app, ready);
259 Ok(())
260}
261
262/// The web inspector for the page asking, the site's in the window:
263/// opened, or with `toggle` opened or closed (F12, from bridge.js). Only
264/// while Developer tools is on in settings (devtools.rs); otherwise an
265/// error that says where to turn it on.
266#[tauri::command]
267pub fn open_devtools(app: AppHandle, webview: Webview, toggle: Option<bool>) -> Result<(), String> {
268 from_site_page(&app, &webview)?;
269 if toggle == Some(true) { devtools::toggle(&app) } else { devtools::open(&app) }
270}
271
272/// What the settings show.
273#[tauri::command]
274pub fn get_settings(app: AppHandle, webview: Webview) -> Result<settings::View, String> {
275 from_settings_or_runner(&webview).or_else(|_| from_site_page(&app, &webview))?;
276 Ok(settings::view(&app))
277}
278
279/// The settings changed something: everything but opening at sign-in.
280///
281/// The site's page may change all of it but one thing: it can't take the
282/// runner out of its sandbox. Jobs that run in Docker or directly on the
283/// computer can reach the person's files, so that choice is made only in
284/// the Runner window, a page bundled with the app, and never by a page
285/// from the network, whatever it loads.
286#[tauri::command]
287pub fn save_settings(app: AppHandle, webview: Webview, settings: Settings) -> Result<settings::View, String> {
288 if from_settings_or_runner(&webview).is_err() {
289 from_site_page(&app, &webview)?;
290 if !site_may_choose(config::settings(&app).runner_backend, settings.runner_backend) {
291 return Err("How jobs run outside the sandbox is chosen in the Runner window.".into());
292 }
293 }
294 settings::apply(&app, settings)?;
295 Ok(settings::view(&app))
296}
297
298/// Whether the site's page may change how jobs run from `before` to
299/// `after`: keeping it, or putting it back in the sandbox.
300fn site_may_choose(before: runner::Backend, after: runner::Backend) -> bool {
301 after == before || after == runner::Backend::Sandboxed
302}
303
304/// Opening at sign-in, which the system keeps.
305#[tauri::command]
306pub fn set_open_at_login(app: AppHandle, webview: Webview, on: bool) -> Result<settings::View, String> {
307 from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
308 settings::open_at_login(&app, on)?;
309 Ok(settings::view(&app))
310}
311
312/// The settings window's "Change…": the main window goes to the launcher's choice of server.
313#[tauri::command]
314pub fn change_server(app: AppHandle, webview: Webview) -> Result<(), String> {
315 from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
316 if let Some(window) = app.get_webview_window(settings::SETTINGS) {
317 let _ = window.close();
318 }
319 crate::tray::launcher(&app, "change");
320 Ok(())
321}
322
323/// The runner's state, diagnosis and recent log: for the site's card for
324/// this computer and the settings window.
325#[tauri::command]
326pub fn runner_status(app: AppHandle, webview: Webview) -> Result<runner::View, String> {
327 from_site(&app, &webview).or_else(|_| from_settings_or_runner(&webview)).or_else(|_| from_bar(&webview))?;
328 Ok(runner::view(&app))
329}
330
331/// The site, on every page: whether someone is signed in, the workspaces
332/// they own (the only ones they can add runners to), and the one they are
333/// in. Answers the workspace a registration token is wanted for, if one is.
334#[tauri::command]
335pub fn runner_offer(app: AppHandle, webview: Webview, signed_in: bool, workspaces: Vec<runner::Offered>, current: Option<String>) -> Result<Option<String>, String> {
336 from_site(&app, &webview)?;
337 runner::offer(&app, signed_in, workspaces, current);
338 Ok(runner::wanted(&app))
339}
340
341/// The site made a registration token for the workspace the app asked
342/// for, or says why not: `allowed` is false when the person may not add
343/// runners there. The token goes to the runner and nowhere else.
344#[tauri::command]
345pub fn runner_register(app: AppHandle, webview: Webview, workspace: String, token: Option<String>, allowed: bool, error: Option<String>) -> Result<(), String> {
346 from_site(&app, &webview)?;
347 runner::register(&app, workspace, token, allowed, error);
348 Ok(())
349}
350
351/// A button for the runner: Start, Stop, or a diagnosis's fix
352/// (runner/diagnosis.rs), from settings or the site's card. With a kind
353/// of job (`capability`: `linux`, `windows`), Start and Stop are its alone.
354#[tauri::command]
355pub fn runner_control(app: AppHandle, webview: Webview, action: String, capability: Option<String>) -> Result<(), String> {
356 from_site(&app, &webview).or_else(|_| from_settings_or_runner(&webview))?;
357 runner::act(&app, &action, capability.as_deref())
358}
359
360#[cfg(test)]
361mod tests {
362 #[test]
363 fn the_bridge_has_nothing_left_to_fill_in() {
364 let script = super::script(true);
365 for placeholder in ["__VERSION__", "__PLATFORM__", "__OUTSIDE__", "__FRAME__"] {
366 assert!(!script.contains(placeholder), "{placeholder} is left in the bridge");
367 }
368 assert!(script.contains(r#"var FRAME = "custom";"#));
369 assert!(super::script(false).contains(r#"var FRAME = "system";"#));
370 assert!(script.contains(r#"var OUTSIDE = ["/pricing""#));
371 }
372
373 #[test]
374 fn the_site_cannot_take_the_runner_out_of_its_sandbox() {
375 use crate::runner::Backend::{Docker, Host, Sandboxed};
376 assert!(super::site_may_choose(Sandboxed, Sandboxed));
377 assert!(super::site_may_choose(Host, Host));
378 assert!(super::site_may_choose(Docker, Sandboxed));
379 assert!(super::site_may_choose(Host, Sandboxed));
380 assert!(!super::site_may_choose(Sandboxed, Host));
381 assert!(!super::site_may_choose(Sandboxed, Docker));
382 assert!(!super::site_may_choose(Docker, Host));
383 }
384}