Skip to content
182 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 /// What the badge shows.
36 pub waiting: Mutex<i64>,
37 /// The app's own settings, as the settings window last left them.
38 pub settings: Mutex<Settings>,
39}
40
41use crate::settings::Settings;
42use crate::updates;
43
44#[derive(Default, Serialize, Deserialize)]
45struct File {
46 #[serde(default)]
47 server: Option<String>,
48 #[serde(flatten)]
49 settings: Settings,
50}
51
52fn path(app: &AppHandle) -> Option<PathBuf> {
53 app.path().app_config_dir().ok().map(|dir| dir.join("desktop.json"))
54}
55
56/// A server as typed: http or https, with a host; only its origin is kept.
57pub fn parse(text: &str) -> Option<Url> {
58 let text = text.trim();
59 let text = if text.contains("://") { text.to_string() } else { format!("https://{text}") };
60 let mut url = Url::parse(&text).ok()?;
61 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
62 return None;
63 }
64 url.set_path("/");
65 url.set_query(None);
66 url.set_fragment(None);
67 Some(url)
68}
69
70/// Reads the saved server, or takes g1t.sh, and the saved settings.
71pub fn load(app: &AppHandle) {
72 let file: File = path(app)
73 .and_then(|path| fs::read(path).ok())
74 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
75 .unwrap_or_default();
76 let server = file.server.as_deref().and_then(parse).unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"));
77 let state = app.state::<State>();
78 *state.server.lock().unwrap() = Some(server);
79 *state.settings.lock().unwrap() = file.settings;
80}
81
82/// Writes the server and the settings as they are now.
83fn save(app: &AppHandle) -> Result<(), String> {
84 let state = app.state::<State>();
85 let file = File {
86 server: state.server.lock().unwrap().as_ref().map(Url::to_string),
87 settings: state.settings.lock().unwrap().clone(),
88 };
89 let path = path(app).ok_or("The app has no folder to keep its settings in.")?;
90 if let Some(dir) = path.parent() {
91 fs::create_dir_all(dir).map_err(|error| error.to_string())?;
92 }
93 fs::write(&path, serde_json::to_vec_pretty(&file).map_err(|error| error.to_string())?).map_err(|error| error.to_string())
94}
95
96pub fn settings(app: &AppHandle) -> Settings {
97 app.state::<State>().settings.lock().unwrap().clone()
98}
99
100/// Keeps new settings.
101pub fn set_settings(app: &AppHandle, settings: Settings) -> Result<(), String> {
102 *app.state::<State>().settings.lock().unwrap() = settings;
103 save(app)
104}
105
106pub fn server(app: &AppHandle) -> Url {
107 app.state::<State>()
108 .server
109 .lock()
110 .unwrap()
111 .clone()
112 .unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"))
113}
114
115/// Saves a new server and lets its pages talk to the app.
116pub fn set_server(app: &AppHandle, text: &str) -> Result<Url, String> {
117 let server = parse(text).ok_or("That is not a web address. It looks like https://g1t.example.com.")?;
118 *app.state::<State>().server.lock().unwrap() = Some(server.clone());
119 save(app)?;
120 allow_site(app).map_err(|error| error.to_string())?;
121 Ok(server)
122}
123
124pub fn origin(url: &Url) -> String {
125 url.origin().ascii_serialization()
126}
127
128/// Whether a page is the server's.
129pub fn is_server(app: &AppHandle, url: &Url) -> bool {
130 matches!(url.scheme(), "http" | "https") && origin(url) == origin(&server(app))
131}
132
133/// Whether a page is the launcher, bundled with the app.
134pub fn is_launcher(url: &Url) -> bool {
135 url.scheme() == "tauri" || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost"))
136}
137
138/// Lets the server's pages call the app: a capability for its origin,
139/// added once. Pages from anywhere else get no IPC at all, and bridge.rs
140/// checks the caller again on every command.
141pub fn allow_site(app: &AppHandle) -> tauri::Result<()> {
142 let origin = origin(&server(app));
143 let state = app.state::<State>();
144 let mut allowed = state.allowed.lock().unwrap();
145 if allowed.iter().any(|known| *known == origin) {
146 return Ok(());
147 }
148 app.add_capability(
149 CapabilityBuilder::new(format!("site-{}", allowed.len()))
150 .window(WINDOW)
151 .remote(format!("{origin}/*"))
152 .permission("core:event:allow-listen")
153 .permission("core:event:allow-unlisten"),
154 )?;
155 allowed.push(origin);
156 Ok(())
157}
158
159/// The window started loading a page.
160pub fn at(app: &AppHandle, url: Url) {
161 *app.state::<State>().at.lock().unwrap() = Some(url);
162}
163
164/// Whether the window is on one of the server's pages now.
165pub fn on_site(app: &AppHandle) -> bool {
166 app.state::<State>().at.lock().unwrap().as_ref().is_some_and(|url| is_server(app, url))
167}
168
169#[cfg(test)]
170mod tests {
171 use super::parse;
172
173 #[test]
174 fn a_server_is_its_origin() {
175 assert_eq!(parse("https://g1t.sh/acme/-/chat?x#y").unwrap().as_str(), "https://g1t.sh/");
176 assert_eq!(parse(" g1t.example.com ").unwrap().as_str(), "https://g1t.example.com/");
177 assert_eq!(parse("http://localhost:5173/login").unwrap().as_str(), "http://localhost:5173/");
178 assert!(parse("ftp://g1t.sh").is_none());
179 assert!(parse("not a url").is_none());
180 assert!(parse("").is_none());
181 }
182}