Skip to content
228 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}
42
43use crate::settings::Settings;
44use crate::updates;
45
46#[derive(Default, Serialize, Deserialize)]
47struct File {
48 #[serde(default)]
49 server: Option<String>,
50 #[serde(flatten)]
51 settings: Settings,
52}
53
54fn path(app: &AppHandle) -> Option<PathBuf> {
55 app.path().app_config_dir().ok().map(|dir| dir.join("desktop.json"))
56}
57
58/// A server as typed: http or https, with a host; only its origin is kept.
59pub fn parse(text: &str) -> Option<Url> {
60 let text = text.trim();
61 let text = if text.contains("://") { text.to_string() } else { format!("https://{text}") };
62 let mut url = Url::parse(&text).ok()?;
63 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
64 return None;
65 }
66 url.set_path("/");
67 url.set_query(None);
68 url.set_fragment(None);
69 Some(url)
70}
71
72/// Reads the saved server, or takes g1t.sh, and the saved settings.
73pub fn load(app: &AppHandle) {
74 let file: File = path(app)
75 .and_then(|path| fs::read(path).ok())
76 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
77 .unwrap_or_default();
78 let server = file.server.as_deref().and_then(parse).unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"));
79 let state = app.state::<State>();
80 *state.server.lock().unwrap() = Some(server);
81 *state.settings.lock().unwrap() = file.settings;
82}
83
84/// Writes the server and the settings as they are now.
85fn save(app: &AppHandle) -> Result<(), String> {
86 let state = app.state::<State>();
87 let file = File {
88 server: state.server.lock().unwrap().as_ref().map(Url::to_string),
89 settings: state.settings.lock().unwrap().clone(),
90 };
91 let path = path(app).ok_or("The app has no folder to keep its settings in.")?;
92 if let Some(dir) = path.parent() {
93 fs::create_dir_all(dir).map_err(|error| error.to_string())?;
94 }
95 fs::write(&path, serde_json::to_vec_pretty(&file).map_err(|error| error.to_string())?).map_err(|error| error.to_string())
96}
97
98pub fn settings(app: &AppHandle) -> Settings {
99 app.state::<State>().settings.lock().unwrap().clone()
100}
101
102/// Keeps new settings.
103pub fn set_settings(app: &AppHandle, settings: Settings) -> Result<(), String> {
104 *app.state::<State>().settings.lock().unwrap() = settings;
105 save(app)
106}
107
108pub fn server(app: &AppHandle) -> Url {
109 app.state::<State>()
110 .server
111 .lock()
112 .unwrap()
113 .clone()
114 .unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"))
115}
116
117/// Saves a new server and lets its pages talk to the app.
118pub fn set_server(app: &AppHandle, text: &str) -> Result<Url, String> {
119 let server = parse(text).ok_or("That is not a web address. It looks like https://g1t.example.com.")?;
120 *app.state::<State>().server.lock().unwrap() = Some(server.clone());
121 save(app)?;
122 allow_site(app).map_err(|error| error.to_string())?;
123 Ok(server)
124}
125
126pub fn origin(url: &Url) -> String {
127 url.origin().ascii_serialization()
128}
129
130/// Whether a page is the server's.
131pub fn is_server(app: &AppHandle, url: &Url) -> bool {
132 matches!(url.scheme(), "http" | "https") && origin(url) == origin(&server(app))
133}
134
135/// Where the bundled pages are served under `tauri dev`: with no `devUrl`
136/// in tauri.conf.json, the Tauri CLI serves `launcher/` itself, from
137/// localhost, and the window starts there. Unset in a release build, which
138/// serves them from inside the app.
139static DEV_SERVER: std::sync::OnceLock<Option<Url>> = std::sync::OnceLock::new();
140
141/// Notes the dev server, once, as the app starts.
142pub fn note_dev_server(app: &AppHandle) {
143 let dev = if cfg!(debug_assertions) { app.config().build.dev_url.clone() } else { None };
144 let _ = DEV_SERVER.set(dev);
145}
146
147pub fn dev_server() -> Option<&'static Url> {
148 DEV_SERVER.get().and_then(Option::as_ref)
149}
150
151/// Whether a page is the launcher, bundled with the app.
152pub fn is_launcher(url: &Url) -> bool {
153 url.scheme() == "tauri"
154 || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost"))
155 || dev_server().is_some_and(|dev| origin(dev) == origin(url))
156}
157
158/// What the server's pages may call: the bridge's commands (bridge.rs).
159const SITE: &[&str] = &[
160 "core:event:allow-listen",
161 "core:event:allow-unlisten",
162 "allow-desktop-info",
163 "allow-notify",
164 "allow-set-badge",
165 "allow-open-url",
166 "allow-check-for-updates",
167 "allow-open-settings",
168 "allow-title-bar-page",
169 "allow-runner-status",
170 "allow-runner-offer",
171 "allow-runner-register",
172 "allow-runner-control",
173];
174
175/// What GitHub's pages may call while someone signs in: telling the title bar their title.
176const FRAME: &[&str] = &["allow-title-bar-page"];
177
178/// Lets the server's pages call the app: a capability for its origin,
179/// added once. Pages from anywhere else get no IPC at all, and bridge.rs
180/// checks the caller again on every command.
181pub fn allow_site(app: &AppHandle) -> tauri::Result<()> {
182 let origin = origin(&server(app));
183 let state = app.state::<State>();
184 let mut allowed = state.allowed.lock().unwrap();
185 if allowed.contains(&origin) {
186 return Ok(());
187 }
188 let mut site = CapabilityBuilder::new(format!("site-{}", allowed.len())).window(WINDOW).remote(format!("{origin}/*"));
189 for permission in SITE {
190 site = site.permission(*permission);
191 }
192 app.add_capability(site)?;
193 // Signing in with GitHub: its pages tell the title bar what they are, and nothing else.
194 if allowed.is_empty() {
195 let mut sign_in = CapabilityBuilder::new("sign-in").window(WINDOW).remote("https://github.com/*".to_string());
196 for permission in FRAME {
197 sign_in = sign_in.permission(*permission);
198 }
199 app.add_capability(sign_in)?;
200 }
201 allowed.push(origin);
202 Ok(())
203}
204
205/// The window started loading a page.
206pub fn at(app: &AppHandle, url: Url) {
207 *app.state::<State>().at.lock().unwrap() = Some(url);
208}
209
210/// Whether the window is on one of the server's pages now.
211pub fn on_site(app: &AppHandle) -> bool {
212 app.state::<State>().at.lock().unwrap().as_ref().is_some_and(|url| is_server(app, url))
213}
214
215#[cfg(test)]
216mod tests {
217 use super::parse;
218
219 #[test]
220 fn a_server_is_its_origin() {
221 assert_eq!(parse("https://g1t.sh/acme/-/chat?x#y").unwrap().as_str(), "https://g1t.sh/");
222 assert_eq!(parse(" g1t.example.com ").unwrap().as_str(), "https://g1t.example.com/");
223 assert_eq!(parse("http://localhost:5173/login").unwrap().as_str(), "http://localhost:5173/");
224 assert!(parse("ftp://g1t.sh").is_none());
225 assert!(parse("not a url").is_none());
226 assert!(parse("").is_none());
227 }
228}