Skip to content
224 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

preparing for cloud work1//! What the app keeps between launches, and what it knows while running.
2//!
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.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`).
preparing for cloud work9
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>>,
Checking for updates in the desktop app answers in its settings window, with a restart beside a downloaded version.35 /// When the updater last heard back from g1t.sh, in milliseconds since 1970.
36 pub checked_at: Mutex<Option<u64>>,
preparing for cloud work37 /// What the badge shows.
38 pub waiting: Mutex<i64>,
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.39 /// The app's own settings, as the settings window last left them.
40 pub settings: Mutex<Settings>,
preparing for cloud work41}
42
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.43use crate::settings::Settings;
preparing for cloud work44use crate::updates;
45
46#[derive(Default, Serialize, Deserialize)]
47struct File {
48 #[serde(default)]
49 server: Option<String>,
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.50 #[serde(flatten)]
51 settings: Settings,
preparing for cloud work52}
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
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.72/// Reads the saved server, or takes g1t.sh, and the saved settings.
preparing for cloud work73pub 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"));
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.79 let state = app.state::<State>();
80 *state.server.lock().unwrap() = Some(server);
81 *state.settings.lock().unwrap() = file.settings;
preparing for cloud work82}
83
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.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
preparing for cloud work108pub 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());
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.121 save(app)?;
preparing for cloud work122 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
The desktop app runs under tauri dev: the launcher served from the CLI's localhost stays in the window.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
preparing for cloud work151/// Whether a page is the launcher, bundled with the app.
152pub fn is_launcher(url: &Url) -> bool {
The desktop app runs under tauri dev: the launcher served from the CLI's localhost stays in the window.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))
preparing for cloud work156}
157
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.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",
The desktop app's title bar is a web view of its own, above the site's, so no page is ever under it.168 "allow-title-bar-page",
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.169];
170
The desktop app's title bar is a web view of its own, above the site's, so no page is ever under it.171/// What GitHub's pages may call while someone signs in: telling the title bar their title.
172const FRAME: &[&str] = &["allow-title-bar-page"];
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.173
preparing for cloud work174/// Lets the server's pages call the app: a capability for its origin,
175/// added once. Pages from anywhere else get no IPC at all, and bridge.rs
176/// checks the caller again on every command.
177pub fn allow_site(app: &AppHandle) -> tauri::Result<()> {
178 let origin = origin(&server(app));
179 let state = app.state::<State>();
180 let mut allowed = state.allowed.lock().unwrap();
181 if allowed.iter().any(|known| *known == origin) {
182 return Ok(());
183 }
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.184 let mut site = CapabilityBuilder::new(format!("site-{}", allowed.len())).window(WINDOW).remote(format!("{origin}/*"));
185 for permission in SITE {
186 site = site.permission(*permission);
187 }
188 app.add_capability(site)?;
The desktop app's title bar is a web view of its own, above the site's, so no page is ever under it.189 // Signing in with GitHub: its pages tell the title bar what they are, and nothing else.
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.190 if allowed.is_empty() {
191 let mut sign_in = CapabilityBuilder::new("sign-in").window(WINDOW).remote("https://github.com/*".to_string());
192 for permission in FRAME {
193 sign_in = sign_in.permission(*permission);
194 }
195 app.add_capability(sign_in)?;
196 }
preparing for cloud work197 allowed.push(origin);
198 Ok(())
199}
200
201/// The window started loading a page.
202pub fn at(app: &AppHandle, url: Url) {
203 *app.state::<State>().at.lock().unwrap() = Some(url);
204}
205
206/// Whether the window is on one of the server's pages now.
207pub fn on_site(app: &AppHandle) -> bool {
208 app.state::<State>().at.lock().unwrap().as_ref().is_some_and(|url| is_server(app, url))
209}
210
211#[cfg(test)]
212mod tests {
213 use super::parse;
214
215 #[test]
216 fn a_server_is_its_origin() {
217 assert_eq!(parse("https://g1t.sh/acme/-/chat?x#y").unwrap().as_str(), "https://g1t.sh/");
218 assert_eq!(parse(" g1t.example.com ").unwrap().as_str(), "https://g1t.example.com/");
219 assert_eq!(parse("http://localhost:5173/login").unwrap().as_str(), "http://localhost:5173/");
220 assert!(parse("ftp://g1t.sh").is_none());
221 assert!(parse("not a url").is_none());
222 assert!(parse("").is_none());
223 }
224}