| 1 | //! The app's own settings: how it starts, what closing the window does, |
| 2 | //! how it tells the person something arrived, its title bar, zoom, the |
| 3 | //! web inspector, updates, and the runner. They open as a dialog over the site, which the |
| 4 | //! site's page draws (apps/web components/desktop-settings.tsx), from the |
| 5 | //! title bar, the menu, the tray, or the site's account menu. While the |
| 6 | //! window shows no page of the site's (the launcher, the offline page, |
| 7 | //! signing in), they open in a window of their own instead |
| 8 | //! (`launcher/settings.html`). They are kept with the server in |
| 9 | //! `desktop.json` (config.rs). Whether g1t opens when someone signs in to |
| 10 | //! their computer is kept by the system itself, through the autostart |
| 11 | //! plugin, and read back from it. |
| 12 | |
| 13 | use serde::{Deserialize, Serialize}; |
| 14 | use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewUrl, WebviewWindowBuilder}; |
| 15 | use tauri_plugin_autostart::ManagerExt; |
| 16 | |
| 17 | use crate::config; |
| 18 | |
| 19 | /// The settings window's label: the settings when the site can't draw them. |
| 20 | pub const SETTINGS: &str = "settings"; |
| 21 | /// The Runner window's label: the runner on its own (launcher/runner.html), |
| 22 | /// when the site can't draw its menu under the title bar's dot. |
| 23 | pub const RUNNER: &str = "runner"; |
| 24 | |
| 25 | /// The argument the system starts g1t with at sign-in, so it can tell a |
| 26 | /// start of its own from someone opening it. |
| 27 | pub const AT_LOGIN: &str = "--at-login"; |
| 28 | |
| 29 | /// The zoom levels the View menu steps through. |
| 30 | pub const ZOOMS: &[f64] = &[0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0]; |
| 31 | |
| 32 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 33 | #[serde(default, rename_all = "camelCase")] |
| 34 | pub struct Settings { |
| 35 | /// Opened at sign-in, g1t waits in the tray instead of opening its window. |
| 36 | pub start_hidden: bool, |
| 37 | /// Closing the window leaves g1t running in the tray, for notifications; |
| 38 | /// otherwise closing it quits. |
| 39 | pub close_to_tray: bool, |
| 40 | /// Notifications from the system, for what the site says needs the person. |
| 41 | pub notifications: bool, |
| 42 | /// The taskbar icon flashes, or the dock icon bounces, with a notification. |
| 43 | pub flash: bool, |
| 44 | /// What needs the person, counted on the icon. |
| 45 | pub badge: bool, |
| 46 | /// The system's title bar, and on Windows and Linux its menu bar, in |
| 47 | /// place of g1t's own (frame.rs). Takes effect when g1t restarts. |
| 48 | pub system_title_bar: bool, |
| 49 | /// The site's zoom, 1 for as it is. |
| 50 | pub zoom: f64, |
| 51 | /// The web inspector for the site's page, from the View menu, F12 and |
| 52 | /// Ctrl+Shift+I (⌥⌘I on macOS); off unless someone turns it on |
| 53 | /// (devtools.rs). On macOS and Linux it takes effect when g1t restarts. |
| 54 | pub developer_tools: bool, |
| 55 | /// Updates download by themselves; a person can still check from the menu. |
| 56 | /// The runner's own updates follow it too (runner/mod.rs). |
| 57 | pub auto_update: bool, |
| 58 | /// Runs the workspace's self-hosted jobs on this computer while g1t is |
| 59 | /// open (runner/mod.rs). On unless someone turns it off. |
| 60 | pub runner: bool, |
| 61 | /// How the runner runs: sandboxed unless someone chooses otherwise. |
| 62 | pub runner_backend: crate::runner::Backend, |
| 63 | /// The workspace it runs for; the one the person is in when it first registers. |
| 64 | pub runner_workspace: Option<String>, |
| 65 | /// Sandboxed, each kind of job on or off on its own: Linux jobs can |
| 66 | /// run while Windows jobs are stopped. All on unless someone stops one. |
| 67 | pub runner_capabilities: RunnerCapabilities, |
| 68 | } |
| 69 | |
| 70 | /// Each kind of job the sandbox runs, on or off (`runnerCapabilities` in |
| 71 | /// desktop.json: `{"linux": true, "windows": false}`). |
| 72 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 73 | #[serde(default)] |
| 74 | pub struct RunnerCapabilities { |
| 75 | pub linux: bool, |
| 76 | pub windows: bool, |
| 77 | pub macos: bool, |
| 78 | } |
| 79 | |
| 80 | impl Default for RunnerCapabilities { |
| 81 | fn default() -> Self { |
| 82 | Self { linux: true, windows: true, macos: true } |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | impl RunnerCapabilities { |
| 87 | /// Whether a kind of job is on; one this app doesn't know is. |
| 88 | pub fn on(&self, id: &str) -> bool { |
| 89 | match id { |
| 90 | "linux" => self.linux, |
| 91 | "windows" => self.windows, |
| 92 | "macos" => self.macos, |
| 93 | _ => true, |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /// Turns a kind of job on or off: false for a kind this app doesn't know. |
| 98 | pub fn set(&mut self, id: &str, on: bool) -> bool { |
| 99 | let kind = match id { |
| 100 | "linux" => &mut self.linux, |
| 101 | "windows" => &mut self.windows, |
| 102 | "macos" => &mut self.macos, |
| 103 | _ => return false, |
| 104 | }; |
| 105 | *kind = on; |
| 106 | true |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | impl Default for Settings { |
| 111 | fn default() -> Self { |
| 112 | Self { |
| 113 | start_hidden: true, |
| 114 | close_to_tray: true, |
| 115 | notifications: true, |
| 116 | flash: true, |
| 117 | badge: true, |
| 118 | system_title_bar: false, |
| 119 | zoom: 1.0, |
| 120 | developer_tools: false, |
| 121 | auto_update: true, |
| 122 | runner: true, |
| 123 | runner_backend: crate::runner::Backend::Sandboxed, |
| 124 | runner_workspace: None, |
| 125 | runner_capabilities: RunnerCapabilities::default(), |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | impl Settings { |
| 131 | /// A zoom the app offers, nearest to the one asked for. |
| 132 | pub fn clamp_zoom(zoom: f64) -> f64 { |
| 133 | ZOOMS.iter().copied().min_by(|a, b| (a - zoom).abs().total_cmp(&(b - zoom).abs())).unwrap_or(1.0) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | /// What the settings window shows: the settings, and what the app and the |
| 138 | /// system know. |
| 139 | #[derive(Serialize)] |
| 140 | #[serde(rename_all = "camelCase")] |
| 141 | pub struct View { |
| 142 | #[serde(flatten)] |
| 143 | pub settings: Settings, |
| 144 | pub open_at_login: bool, |
| 145 | pub version: String, |
| 146 | pub platform: String, |
| 147 | pub server: String, |
| 148 | pub update_ready: Option<String>, |
| 149 | /// When the app last asked for updates and heard back, in milliseconds since 1970. |
| 150 | pub checked_at: Option<u64>, |
| 151 | /// The title bar setting differs from the one the windows have: a restart applies it. |
| 152 | pub title_bar_changed: bool, |
| 153 | /// Whether this window draws g1t's own title bar, which the page then draws (settings.html). |
| 154 | pub custom_frame: bool, |
| 155 | /// The developer tools setting differs from the one the site's page |
| 156 | /// has, on macOS and Linux: a restart applies it (devtools.rs). |
| 157 | pub developer_tools_changed: bool, |
| 158 | /// The runner's state, diagnosis and recent log. |
| 159 | pub runner_view: crate::runner::View, |
| 160 | } |
| 161 | |
| 162 | pub fn view(app: &AppHandle) -> View { |
| 163 | View { |
| 164 | settings: config::settings(app), |
| 165 | open_at_login: app.autolaunch().is_enabled().unwrap_or(false), |
| 166 | version: env!("CARGO_PKG_VERSION").into(), |
| 167 | platform: std::env::consts::OS.into(), |
| 168 | server: config::server(app).to_string(), |
| 169 | update_ready: crate::updates::ready(app), |
| 170 | checked_at: crate::updates::checked_at(app), |
| 171 | title_bar_changed: crate::frame::started(app) != crate::frame::custom(app), |
| 172 | custom_frame: crate::frame::started(app), |
| 173 | developer_tools_changed: crate::devtools::changed(app), |
| 174 | runner_view: crate::runner::view(app), |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | /// Keeps new settings and puts them to work at once. |
| 179 | pub fn apply(app: &AppHandle, mut settings: Settings) -> Result<(), String> { |
| 180 | settings.zoom = Settings::clamp_zoom(settings.zoom); |
| 181 | let before = config::settings(app); |
| 182 | config::set_settings(app, settings.clone())?; |
| 183 | if let Some(site) = crate::frame::site(app).filter(|_| before.zoom != settings.zoom) { |
| 184 | let _ = site.set_zoom(settings.zoom); |
| 185 | } |
| 186 | if before.badge != settings.badge { |
| 187 | crate::badge::refresh(app); |
| 188 | } |
| 189 | if before.developer_tools != settings.developer_tools { |
| 190 | crate::devtools::setting_changed(app); |
| 191 | } |
| 192 | let runner = |s: &Settings| (s.runner, s.runner_backend, s.runner_workspace.clone(), s.runner_capabilities.clone()); |
| 193 | if runner(&before) != runner(&settings) { |
| 194 | crate::runner::settings_changed(app); |
| 195 | } |
| 196 | Ok(()) |
| 197 | } |
| 198 | |
| 199 | /// Turns opening at sign-in on or off, with the system. |
| 200 | pub fn open_at_login(app: &AppHandle, on: bool) -> Result<(), String> { |
| 201 | let autolaunch = app.autolaunch(); |
| 202 | let result = if on { autolaunch.enable() } else { autolaunch.disable() }; |
| 203 | result.map_err(|error| error.to_string()) |
| 204 | } |
| 205 | |
| 206 | /// Steps the zoom in (1), out (-1), or back to as it is (0). |
| 207 | pub fn zoom(app: &AppHandle, step: i32) { |
| 208 | let mut settings = config::settings(app); |
| 209 | let at = ZOOMS.iter().position(|zoom| *zoom == Settings::clamp_zoom(settings.zoom)).unwrap_or(4); |
| 210 | settings.zoom = match step { |
| 211 | 0 => 1.0, |
| 212 | 1 => ZOOMS[(at + 1).min(ZOOMS.len() - 1)], |
| 213 | _ => ZOOMS[at.saturating_sub(1)], |
| 214 | }; |
| 215 | let _ = apply(app, settings); |
| 216 | } |
| 217 | |
| 218 | /// Whether this launch is the system's, at sign-in, and g1t should wait in the tray. |
| 219 | pub fn starts_hidden(app: &AppHandle) -> bool { |
| 220 | std::env::args().any(|arg| arg == AT_LOGIN) && config::settings(app).start_hidden |
| 221 | } |
| 222 | |
| 223 | /// What the settings window listens for: check for updates now. |
| 224 | pub const CHECK_EVENT: &str = "g1t-check-updates"; |
| 225 | /// What it listens for: an update is downloaded, with its version. |
| 226 | pub const READY_EVENT: &str = "g1t-update-ready"; |
| 227 | /// What the site's page listens for: open the settings dialog (`Opening`). |
| 228 | pub const OPEN_EVENT: &str = "g1t-open-settings"; |
| 229 | /// What the site's page listens for: the runner menu under the title |
| 230 | /// bar's dot (`RunnerMenu`). |
| 231 | pub const RUNNER_MENU_EVENT: &str = "g1t-runner-menu"; |
| 232 | |
| 233 | /// The settings dialog, opened at a section (`runner`, `updates`), and |
| 234 | /// looking for updates as it opens when `check` is set. |
| 235 | #[derive(Clone, Debug, Serialize)] |
| 236 | pub struct Opening { |
| 237 | pub section: Option<&'static str>, |
| 238 | pub check: bool, |
| 239 | } |
| 240 | |
| 241 | /// The runner menu: `toggle`, from the dot, or `open`. `at` is where the |
| 242 | /// dot is across the bar, as a share of its width, so the menu hangs under |
| 243 | /// it whatever the site's zoom. |
| 244 | #[derive(Clone, Debug, Serialize)] |
| 245 | pub struct RunnerMenu { |
| 246 | pub action: &'static str, |
| 247 | pub at: Option<f64>, |
| 248 | } |
| 249 | |
| 250 | /// Whether the site's page can draw the settings and the runner menu |
| 251 | /// itself: the window is on one of the server's pages, and that page said |
| 252 | /// it can (`site_ready` in bridge.rs). A page of an older site never says |
| 253 | /// so, and they open in windows of their own, as they did. |
| 254 | fn in_site(app: &AppHandle) -> bool { |
| 255 | config::on_site(app) && config::site_ready(app) |
| 256 | } |
| 257 | |
| 258 | /// Opens the settings, or brings them forward. |
| 259 | pub fn open(app: &AppHandle) { |
| 260 | open_at(app, None, false); |
| 261 | } |
| 262 | |
| 263 | /// "Check for updates…" in a menu: the settings open at Updates and check, |
| 264 | /// and say what they found, the download included. |
| 265 | pub fn open_and_check(app: &AppHandle) { |
| 266 | open_at(app, Some("updates"), true); |
| 267 | } |
| 268 | |
| 269 | /// The settings at their Runner section: how jobs run, the workspace, each |
| 270 | /// kind of job. A fix that asks the person to choose one opens them there. |
| 271 | pub fn open_runner_settings(app: &AppHandle) { |
| 272 | if in_site(app) { |
| 273 | open_at(app, Some("runner"), false); |
| 274 | } else { |
| 275 | open_runner_window(app); |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | /// The settings over the site, the main window shown if it was hidden; in |
| 280 | /// a window of their own while the site isn't there to draw them. |
| 281 | fn open_at(app: &AppHandle, section: Option<&'static str>, check: bool) { |
| 282 | if in_site(app) { |
| 283 | crate::tray::show(app); |
| 284 | let _ = app.emit_to(EventTarget::webview(crate::frame::SITE), OPEN_EVENT, Opening { section, check }); |
| 285 | return; |
| 286 | } |
| 287 | show(app, check); |
| 288 | } |
| 289 | |
| 290 | /// The runner menu, opened (the tray's runner row) or, from the title |
| 291 | /// bar's dot, opened or closed. While the site can't draw it, the Runner |
| 292 | /// window. |
| 293 | pub fn runner_menu(app: &AppHandle, toggle: bool, at: Option<f64>) { |
| 294 | if in_site(app) { |
| 295 | crate::tray::show(app); |
| 296 | let at = at.filter(|at| at.is_finite()).map(|at| at.clamp(0.0, 1.0)); |
| 297 | let menu = RunnerMenu { action: if toggle { "toggle" } else { "open" }, at }; |
| 298 | let _ = app.emit_to(EventTarget::webview(crate::frame::SITE), RUNNER_MENU_EVENT, menu); |
| 299 | return; |
| 300 | } |
| 301 | open_runner_window(app); |
| 302 | } |
| 303 | |
| 304 | /// Opens the Runner window, or brings it forward. |
| 305 | pub fn open_runner_window(app: &AppHandle) { |
| 306 | if !forward(app, RUNNER) { |
| 307 | made_aside(app, Window { label: RUNNER, page: "runner.html", title: "g1t runner", size: (720.0, 760.0) }); |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | /// Brings a window that is open forward: false when it isn't. |
| 312 | fn forward(app: &AppHandle, label: &str) -> bool { |
| 313 | let Some(window) = app.get_webview_window(label) else { return false }; |
| 314 | let _ = window.unminimize(); |
| 315 | let _ = window.show(); |
| 316 | let _ = window.set_focus(); |
| 317 | true |
| 318 | } |
| 319 | |
| 320 | fn show(app: &AppHandle, check: bool) { |
| 321 | if forward(app, SETTINGS) { |
| 322 | if check { |
| 323 | let _ = app.emit_to(SETTINGS, CHECK_EVENT, ()); |
| 324 | } |
| 325 | return; |
| 326 | } |
| 327 | // A window still loading has no listener yet: the address asks instead. |
| 328 | let page = if check { "settings.html?check" } else { "settings.html" }; |
| 329 | made_aside(app, Window { label: SETTINGS, page, title: "g1t settings", size: (620.0, 720.0) }); |
| 330 | } |
| 331 | |
| 332 | /// A window of the app's own pages. |
| 333 | struct Window { |
| 334 | label: &'static str, |
| 335 | page: &'static str, |
| 336 | title: &'static str, |
| 337 | size: (f64, f64), |
| 338 | } |
| 339 | |
| 340 | /// Makes a window on a thread of its own, whoever asks. The app's windows |
| 341 | /// open from synchronous commands too (the title bar's buttons, the site's |
| 342 | /// account menu), which run on the main thread, and on Windows a window |
| 343 | /// made there deadlocks: it appears, white, and its page never loads. From |
| 344 | /// another thread, Tauri hands the making to the event loop and waits. |
| 345 | fn made_aside(app: &AppHandle, window: Window) { |
| 346 | let app = app.clone(); |
| 347 | let spawned = std::thread::Builder::new().name(format!("g1t-{}-window", window.label)).spawn(move || build(&app, &window)); |
| 348 | if let Err(error) = spawned { |
| 349 | eprintln!("g1t: a window could not open: {error}"); |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | fn build(app: &AppHandle, window: &Window) { |
| 354 | let custom = crate::frame::started(app); |
| 355 | // Never an inspector: the devtools feature gives every web view one |
| 356 | // unless it is told otherwise (src/devtools.rs). |
| 357 | let builder = WebviewWindowBuilder::new(app, window.label, WebviewUrl::App(window.page.into())) |
| 358 | .title(window.title) |
| 359 | .devtools(false); |
| 360 | let built = crate::frame::dialog(builder, custom) |
| 361 | .inner_size(window.size.0, window.size.1) |
| 362 | .min_inner_size(440.0, 420.0) |
| 363 | .maximizable(false) |
| 364 | .minimizable(false) |
| 365 | .center() |
| 366 | // The page is bundled with the app; its links go to the browser (settings.js). |
| 367 | .on_navigation(config::is_launcher) |
| 368 | .build(); |
| 369 | match built { |
| 370 | Ok(window) => { |
| 371 | let _ = window.set_focus(); |
| 372 | } |
| 373 | Err(error) => eprintln!("g1t: the {} window could not open: {error}", window.label), |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | #[cfg(test)] |
| 378 | mod tests { |
| 379 | use super::Settings; |
| 380 | |
| 381 | #[test] |
| 382 | fn a_missing_setting_is_its_default() { |
| 383 | let settings: Settings = serde_json::from_str(r#"{"closeToTray":false}"#).unwrap(); |
| 384 | assert!(!settings.close_to_tray); |
| 385 | assert!(settings.notifications); |
| 386 | assert_eq!(settings.zoom, 1.0); |
| 387 | // The web inspector is off for someone who never turned it on. |
| 388 | assert!(!settings.developer_tools); |
| 389 | let developer: Settings = serde_json::from_str(r#"{"developerTools":true}"#).unwrap(); |
| 390 | assert!(developer.developer_tools); |
| 391 | // The runner is on, and sandboxed, for someone who never chose. |
| 392 | assert!(settings.runner); |
| 393 | assert_eq!(settings.runner_backend, crate::runner::Backend::Sandboxed); |
| 394 | let chosen: Settings = serde_json::from_str(r#"{"runner":false,"runnerBackend":"host","runnerWorkspace":"acme"}"#).unwrap(); |
| 395 | assert!(!chosen.runner); |
| 396 | assert_eq!(chosen.runner_backend, crate::runner::Backend::Host); |
| 397 | assert_eq!(chosen.runner_workspace.as_deref(), Some("acme")); |
| 398 | // Every kind of job is on until someone stops one; a kind left out is on. |
| 399 | assert!(chosen.runner_capabilities.on("linux") && chosen.runner_capabilities.on("windows")); |
| 400 | let one_off: Settings = serde_json::from_str(r#"{"runnerCapabilities":{"windows":false}}"#).unwrap(); |
| 401 | assert!(one_off.runner_capabilities.on("linux")); |
| 402 | assert!(!one_off.runner_capabilities.on("windows")); |
| 403 | let mut kinds = one_off.runner_capabilities.clone(); |
| 404 | assert!(kinds.set("windows", true) && kinds.on("windows")); |
| 405 | assert!(!kinds.set("plan9", false)); |
| 406 | } |
| 407 | |
| 408 | #[test] |
| 409 | fn zoom_is_one_the_app_offers() { |
| 410 | assert_eq!(Settings::clamp_zoom(1.07), 1.1); |
| 411 | assert_eq!(Settings::clamp_zoom(9.0), 2.0); |
| 412 | assert_eq!(Settings::clamp_zoom(0.1), 0.67); |
| 413 | } |
| 414 | } |