Skip to content
207 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
135/// Whether a page is the launcher, bundled with the app.
136pub fn is_launcher(url: &Url) -> bool {
137 url.scheme() == "tauri" || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost"))
138}
139
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.140/// What the server's pages may call: the bridge's commands (bridge.rs).
141const SITE: &[&str] = &[
142 "core:event:allow-listen",
143 "core:event:allow-unlisten",
144 "allow-desktop-info",
145 "allow-notify",
146 "allow-set-badge",
147 "allow-open-url",
148 "allow-check-for-updates",
149 "allow-open-settings",
150 "allow-window-control",
151 "allow-window-menu",
152];
153
154/// What GitHub's pages may call while someone signs in: the title bar's buttons.
155const FRAME: &[&str] = &["allow-window-control", "allow-window-menu"];
156
preparing for cloud work157/// Lets the server's pages call the app: a capability for its origin,
158/// added once. Pages from anywhere else get no IPC at all, and bridge.rs
159/// checks the caller again on every command.
160pub fn allow_site(app: &AppHandle) -> tauri::Result<()> {
161 let origin = origin(&server(app));
162 let state = app.state::<State>();
163 let mut allowed = state.allowed.lock().unwrap();
164 if allowed.iter().any(|known| *known == origin) {
165 return Ok(());
166 }
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.167 let mut site = CapabilityBuilder::new(format!("site-{}", allowed.len())).window(WINDOW).remote(format!("{origin}/*"));
168 for permission in SITE {
169 site = site.permission(*permission);
170 }
171 app.add_capability(site)?;
172 // Signing in with GitHub: its pages get the title bar's buttons, and nothing else.
173 if allowed.is_empty() {
174 let mut sign_in = CapabilityBuilder::new("sign-in").window(WINDOW).remote("https://github.com/*".to_string());
175 for permission in FRAME {
176 sign_in = sign_in.permission(*permission);
177 }
178 app.add_capability(sign_in)?;
179 }
preparing for cloud work180 allowed.push(origin);
181 Ok(())
182}
183
184/// The window started loading a page.
185pub fn at(app: &AppHandle, url: Url) {
186 *app.state::<State>().at.lock().unwrap() = Some(url);
187}
188
189/// Whether the window is on one of the server's pages now.
190pub fn on_site(app: &AppHandle) -> bool {
191 app.state::<State>().at.lock().unwrap().as_ref().is_some_and(|url| is_server(app, url))
192}
193
194#[cfg(test)]
195mod tests {
196 use super::parse;
197
198 #[test]
199 fn a_server_is_its_origin() {
200 assert_eq!(parse("https://g1t.sh/acme/-/chat?x#y").unwrap().as_str(), "https://g1t.sh/");
201 assert_eq!(parse(" g1t.example.com ").unwrap().as_str(), "https://g1t.example.com/");
202 assert_eq!(parse("http://localhost:5173/login").unwrap().as_str(), "http://localhost:5173/");
203 assert!(parse("ftp://g1t.sh").is_none());
204 assert!(parse("not a url").is_none());
205 assert!(parse("").is_none());
206 }
207}