Skip to content
155 linesCodeBlameRaw
1//! What the app keeps between launches, and what it knows while running.
2//!
3//! The one setting is the server: `https://g1t.sh` unless someone runs a
4//! g1t of their own, kept in `desktop.json` in the app's config folder
5//! (`~/.config/g1t` on Linux, `~/Library/Application Support/sh.g1t.desktop`
6//! on macOS, `%APPDATA%\sh.g1t.desktop` on Windows). The server's pages are
7//! the only remote pages allowed to talk to the app (`allow_site`).
8
9use std::fs;
10use std::path::PathBuf;
11use std::sync::Mutex;
12
13use serde::{Deserialize, Serialize};
14use tauri::ipc::CapabilityBuilder;
15use tauri::{AppHandle, Manager};
16use url::Url;
17
18use crate::WINDOW;
19
20pub const DEFAULT_SERVER: &str = "https://g1t.sh";
21
22/// What the running app knows: the server, where the window is, and what
23/// the updater has ready.
24#[derive(Default)]
25pub struct State {
26 /// The server, as an origin with a trailing slash: `https://g1t.sh/`.
27 pub server: Mutex<Option<Url>>,
28 /// The page the window last started loading.
29 pub at: Mutex<Option<Url>>,
30 /// Origins whose pages may call the app, each given its capability once.
31 pub allowed: Mutex<Vec<String>>,
32 /// An update downloaded and waiting to be installed, and its version.
33 pub update: Mutex<Option<updates::Ready>>,
34 /// What the badge shows.
35 pub waiting: Mutex<i64>,
36}
37
38use crate::updates;
39
40#[derive(Default, Serialize, Deserialize)]
41struct File {
42 #[serde(default)]
43 server: Option<String>,
44}
45
46fn path(app: &AppHandle) -> Option<PathBuf> {
47 app.path().app_config_dir().ok().map(|dir| dir.join("desktop.json"))
48}
49
50/// A server as typed: http or https, with a host; only its origin is kept.
51pub fn parse(text: &str) -> Option<Url> {
52 let text = text.trim();
53 let text = if text.contains("://") { text.to_string() } else { format!("https://{text}") };
54 let mut url = Url::parse(&text).ok()?;
55 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
56 return None;
57 }
58 url.set_path("/");
59 url.set_query(None);
60 url.set_fragment(None);
61 Some(url)
62}
63
64/// Reads the saved server, or takes g1t.sh.
65pub fn load(app: &AppHandle) {
66 let file: File = path(app)
67 .and_then(|path| fs::read(path).ok())
68 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
69 .unwrap_or_default();
70 let server = file.server.as_deref().and_then(parse).unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"));
71 *app.state::<State>().server.lock().unwrap() = Some(server);
72}
73
74pub fn server(app: &AppHandle) -> Url {
75 app.state::<State>()
76 .server
77 .lock()
78 .unwrap()
79 .clone()
80 .unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"))
81}
82
83/// Saves a new server and lets its pages talk to the app.
84pub fn set_server(app: &AppHandle, text: &str) -> Result<Url, String> {
85 let server = parse(text).ok_or("That is not a web address. It looks like https://g1t.example.com.")?;
86 let file = File { server: Some(server.to_string()) };
87 let path = path(app).ok_or("The app has no folder to keep its settings in.")?;
88 if let Some(dir) = path.parent() {
89 fs::create_dir_all(dir).map_err(|error| error.to_string())?;
90 }
91 fs::write(&path, serde_json::to_vec_pretty(&file).map_err(|error| error.to_string())?).map_err(|error| error.to_string())?;
92 *app.state::<State>().server.lock().unwrap() = Some(server.clone());
93 allow_site(app).map_err(|error| error.to_string())?;
94 Ok(server)
95}
96
97pub fn origin(url: &Url) -> String {
98 url.origin().ascii_serialization()
99}
100
101/// Whether a page is the server's.
102pub fn is_server(app: &AppHandle, url: &Url) -> bool {
103 matches!(url.scheme(), "http" | "https") && origin(url) == origin(&server(app))
104}
105
106/// Whether a page is the launcher, bundled with the app.
107pub fn is_launcher(url: &Url) -> bool {
108 url.scheme() == "tauri" || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost"))
109}
110
111/// Lets the server's pages call the app: a capability for its origin,
112/// added once. Pages from anywhere else get no IPC at all, and bridge.rs
113/// checks the caller again on every command.
114pub fn allow_site(app: &AppHandle) -> tauri::Result<()> {
115 let origin = origin(&server(app));
116 let state = app.state::<State>();
117 let mut allowed = state.allowed.lock().unwrap();
118 if allowed.iter().any(|known| *known == origin) {
119 return Ok(());
120 }
121 app.add_capability(
122 CapabilityBuilder::new(format!("site-{}", allowed.len()))
123 .window(WINDOW)
124 .remote(format!("{origin}/*"))
125 .permission("core:event:allow-listen")
126 .permission("core:event:allow-unlisten"),
127 )?;
128 allowed.push(origin);
129 Ok(())
130}
131
132/// The window started loading a page.
133pub fn at(app: &AppHandle, url: Url) {
134 *app.state::<State>().at.lock().unwrap() = Some(url);
135}
136
137/// Whether the window is on one of the server's pages now.
138pub fn on_site(app: &AppHandle) -> bool {
139 app.state::<State>().at.lock().unwrap().as_ref().is_some_and(|url| is_server(app, url))
140}
141
142#[cfg(test)]
143mod tests {
144 use super::parse;
145
146 #[test]
147 fn a_server_is_its_origin() {
148 assert_eq!(parse("https://g1t.sh/acme/-/chat?x#y").unwrap().as_str(), "https://g1t.sh/");
149 assert_eq!(parse(" g1t.example.com ").unwrap().as_str(), "https://g1t.example.com/");
150 assert_eq!(parse("http://localhost:5173/login").unwrap().as_str(), "http://localhost:5173/");
151 assert!(parse("ftp://g1t.sh").is_none());
152 assert!(parse("not a url").is_none());
153 assert!(parse("").is_none());
154 }
155}