Skip to content
414 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, 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
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 /// 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)]
74pub struct RunnerCapabilities {
75 pub linux: bool,
76 pub windows: bool,
77 pub macos: bool,
78}
79
80impl Default for RunnerCapabilities {
81 fn default() -> Self {
82 Self { linux: true, windows: true, macos: true }
83 }
84}
85
86impl 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
110impl 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
130impl 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")]
141pub 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
162pub 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.
179pub 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.
200pub 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).
207pub 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.
219pub 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.
224pub const CHECK_EVENT: &str = "g1t-check-updates";
225/// What it listens for: an update is downloaded, with its version.
226pub const READY_EVENT: &str = "g1t-update-ready";
227/// What the site's page listens for: open the settings dialog (`Opening`).
228pub 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`).
231pub 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)]
236pub 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)]
245pub 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.
254fn in_site(app: &AppHandle) -> bool {
255 config::on_site(app) && config::site_ready(app)
256}
257
258/// Opens the settings, or brings them forward.
259pub 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.
265pub 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.
271pub 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.
281fn 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.
293pub 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.
305pub 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.
312fn 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
320fn 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.
333struct 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.
345fn 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
353fn 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)]
378mod 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}