Skip to content
256 linesCodeBlameRaw
1//! What the app keeps between launches, and what it knows while running.
2//!
3//! The server, `https://g1t.sh` unless someone runs a g1t of their own,
4//! and the app's own settings (settings.rs) are kept in `desktop.json` in
5//! the app's config folder (`~/.config/sh.g1t.desktop` on Linux,
6//! `~/Library/Application Support/sh.g1t.desktop` on macOS,
7//! `%APPDATA%\sh.g1t.desktop` on Windows). The server's pages are the only
8//! remote pages allowed to talk to the app (`allow_site`).
9
10use std::fs;
11use std::path::PathBuf;
12use std::sync::Mutex;
13
14use serde::{Deserialize, Serialize};
15use tauri::ipc::CapabilityBuilder;
16use tauri::{AppHandle, Manager};
17use url::Url;
18
19use crate::WINDOW;
20
21pub const DEFAULT_SERVER: &str = "https://g1t.sh";
22
23/// What the running app knows: the server, where the window is, and what
24/// the updater has ready.
25#[derive(Default)]
26pub struct State {
27 /// The server, as an origin with a trailing slash: `https://g1t.sh/`.
28 pub server: Mutex<Option<Url>>,
29 /// The page the window last started loading.
30 pub at: Mutex<Option<Url>>,
31 /// Origins whose pages may call the app, each given its capability once.
32 pub allowed: Mutex<Vec<String>>,
33 /// An update downloaded and waiting to be installed, and its version.
34 pub update: Mutex<Option<updates::Ready>>,
35 /// When the updater last heard back from g1t.sh, in milliseconds since 1970.
36 pub checked_at: Mutex<Option<u64>>,
37 /// What the badge shows.
38 pub waiting: Mutex<i64>,
39 /// The app's own settings, as the settings window last left them.
40 pub settings: Mutex<Settings>,
41 /// The site's page in the window draws the settings and the runner
42 /// menu itself: it said so once it could, and each page that starts
43 /// loading forgets it until it says so again (`site_ready`).
44 pub site_ready: Mutex<bool>,
45}
46
47use crate::settings::Settings;
48use crate::updates;
49
50#[derive(Default, Serialize, Deserialize)]
51struct File {
52 #[serde(default)]
53 server: Option<String>,
54 #[serde(flatten)]
55 settings: Settings,
56}
57
58fn path(app: &AppHandle) -> Option<PathBuf> {
59 app.path().app_config_dir().ok().map(|dir| dir.join("desktop.json"))
60}
61
62/// A server as typed: http or https, with a host; only its origin is kept.
63pub fn parse(text: &str) -> Option<Url> {
64 let text = text.trim();
65 let text = if text.contains("://") { text.to_string() } else { format!("https://{text}") };
66 let mut url = Url::parse(&text).ok()?;
67 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
68 return None;
69 }
70 url.set_path("/");
71 url.set_query(None);
72 url.set_fragment(None);
73 Some(url)
74}
75
76/// Reads the saved server, or takes g1t.sh, and the saved settings.
77pub fn load(app: &AppHandle) {
78 let file: File = path(app)
79 .and_then(|path| fs::read(path).ok())
80 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
81 .unwrap_or_default();
82 let server = file.server.as_deref().and_then(parse).unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"));
83 let state = app.state::<State>();
84 *state.server.lock().unwrap() = Some(server);
85 *state.settings.lock().unwrap() = file.settings;
86}
87
88/// Writes the server and the settings as they are now.
89fn save(app: &AppHandle) -> Result<(), String> {
90 let state = app.state::<State>();
91 let file = File {
92 server: state.server.lock().unwrap().as_ref().map(Url::to_string),
93 settings: state.settings.lock().unwrap().clone(),
94 };
95 let path = path(app).ok_or("The app has no folder to keep its settings in.")?;
96 if let Some(dir) = path.parent() {
97 fs::create_dir_all(dir).map_err(|error| error.to_string())?;
98 }
99 fs::write(&path, serde_json::to_vec_pretty(&file).map_err(|error| error.to_string())?).map_err(|error| error.to_string())
100}
101
102pub fn settings(app: &AppHandle) -> Settings {
103 app.state::<State>().settings.lock().unwrap().clone()
104}
105
106/// Keeps new settings.
107pub fn set_settings(app: &AppHandle, settings: Settings) -> Result<(), String> {
108 *app.state::<State>().settings.lock().unwrap() = settings;
109 save(app)
110}
111
112pub fn server(app: &AppHandle) -> Url {
113 app.state::<State>()
114 .server
115 .lock()
116 .unwrap()
117 .clone()
118 .unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"))
119}
120
121/// Saves a new server and lets its pages talk to the app.
122pub fn set_server(app: &AppHandle, text: &str) -> Result<Url, String> {
123 let server = parse(text).ok_or("That is not a web address. It looks like https://g1t.example.com.")?;
124 *app.state::<State>().server.lock().unwrap() = Some(server.clone());
125 save(app)?;
126 allow_site(app).map_err(|error| error.to_string())?;
127 Ok(server)
128}
129
130pub fn origin(url: &Url) -> String {
131 url.origin().ascii_serialization()
132}
133
134/// Whether a page is the server's.
135pub fn is_server(app: &AppHandle, url: &Url) -> bool {
136 matches!(url.scheme(), "http" | "https") && origin(url) == origin(&server(app))
137}
138
139/// Where the bundled pages are served under `tauri dev`: with no `devUrl`
140/// in tauri.conf.json, the Tauri CLI serves `launcher/` itself, from
141/// localhost, and the window starts there. Unset in a release build, which
142/// serves them from inside the app.
143static DEV_SERVER: std::sync::OnceLock<Option<Url>> = std::sync::OnceLock::new();
144
145/// Notes the dev server, once, as the app starts.
146pub fn note_dev_server(app: &AppHandle) {
147 let dev = if cfg!(debug_assertions) { app.config().build.dev_url.clone() } else { None };
148 let _ = DEV_SERVER.set(dev);
149}
150
151pub fn dev_server() -> Option<&'static Url> {
152 DEV_SERVER.get().and_then(Option::as_ref)
153}
154
155/// Whether a page is the launcher, bundled with the app.
156pub fn is_launcher(url: &Url) -> bool {
157 url.scheme() == "tauri"
158 || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost"))
159 || dev_server().is_some_and(|dev| origin(dev) == origin(url))
160}
161
162/// What the server's pages may call: the bridge's commands (bridge.rs).
163const SITE: &[&str] = &[
164 "core:event:allow-listen",
165 "core:event:allow-unlisten",
166 "allow-desktop-info",
167 "allow-notify",
168 "allow-set-badge",
169 "allow-open-url",
170 "allow-check-for-updates",
171 "allow-open-settings",
172 "allow-title-bar-page",
173 "allow-runner-status",
174 "allow-runner-offer",
175 "allow-runner-register",
176 "allow-runner-control",
177 // The settings dialog and the runner menu, which the site draws over itself.
178 "allow-site-ready",
179 "allow-get-settings",
180 "allow-save-settings",
181 "allow-set-open-at-login",
182 "allow-restart",
183 "allow-restart-to-update",
184 "allow-change-server",
185 // The web inspector, which answers only while Developer tools is on.
186 "allow-open-devtools",
187];
188
189/// What GitHub's pages may call while someone signs in: telling the title bar their title.
190const FRAME: &[&str] = &["allow-title-bar-page"];
191
192/// Lets the server's pages call the app: a capability for its origin,
193/// added once. Pages from anywhere else get no IPC at all, and bridge.rs
194/// checks the caller again on every command.
195pub fn allow_site(app: &AppHandle) -> tauri::Result<()> {
196 let origin = origin(&server(app));
197 let state = app.state::<State>();
198 let mut allowed = state.allowed.lock().unwrap();
199 if allowed.contains(&origin) {
200 return Ok(());
201 }
202 let mut site = CapabilityBuilder::new(format!("site-{}", allowed.len())).window(WINDOW).remote(format!("{origin}/*"));
203 for permission in SITE {
204 site = site.permission(*permission);
205 }
206 app.add_capability(site)?;
207 // Signing in with GitHub: its pages tell the title bar what they are, and nothing else.
208 if allowed.is_empty() {
209 let mut sign_in = CapabilityBuilder::new("sign-in").window(WINDOW).remote("https://github.com/*".to_string());
210 for permission in FRAME {
211 sign_in = sign_in.permission(*permission);
212 }
213 app.add_capability(sign_in)?;
214 }
215 allowed.push(origin);
216 Ok(())
217}
218
219/// The window started loading a page (`started`), or finished. A page
220/// that starts loading hasn't said it can draw the settings yet.
221pub fn at(app: &AppHandle, url: Url, started: bool) {
222 let state = app.state::<State>();
223 *state.at.lock().unwrap() = Some(url);
224 if started {
225 *state.site_ready.lock().unwrap() = false;
226 }
227}
228
229/// The site's page says whether it draws the settings and the runner menu.
230pub fn set_site_ready(app: &AppHandle, ready: bool) {
231 *app.state::<State>().site_ready.lock().unwrap() = ready;
232}
233
234pub fn site_ready(app: &AppHandle) -> bool {
235 *app.state::<State>().site_ready.lock().unwrap()
236}
237
238/// Whether the window is on one of the server's pages now.
239pub fn on_site(app: &AppHandle) -> bool {
240 app.state::<State>().at.lock().unwrap().as_ref().is_some_and(|url| is_server(app, url))
241}
242
243#[cfg(test)]
244mod tests {
245 use super::parse;
246
247 #[test]
248 fn a_server_is_its_origin() {
249 assert_eq!(parse("https://g1t.sh/acme/-/chat?x#y").unwrap().as_str(), "https://g1t.sh/");
250 assert_eq!(parse(" g1t.example.com ").unwrap().as_str(), "https://g1t.example.com/");
251 assert_eq!(parse("http://localhost:5173/login").unwrap().as_str(), "http://localhost:5173/");
252 assert!(parse("ftp://g1t.sh").is_none());
253 assert!(parse("not a url").is_none());
254 assert!(parse("").is_none());
255 }
256}