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