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 work | 1 | //! What the app keeps between launches, and what it knows while running. |
| 2 | //! | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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 work | 9 | |
| 10 | use std::fs; | |
| 11 | use std::path::PathBuf; | |
| 12 | use std::sync::Mutex; | |
| 13 | ||
| 14 | use serde::{Deserialize, Serialize}; | |
| 15 | use tauri::ipc::CapabilityBuilder; | |
| 16 | use tauri::{AppHandle, Manager}; | |
| 17 | use url::Url; | |
| 18 | ||
| 19 | use crate::WINDOW; | |
| 20 | ||
| 21 | pub 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)] | |
| 26 | pub 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>>, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 35 | /// When the updater last heard back from g1t.sh, in milliseconds since 1970. |
| 36 | pub checked_at: Mutex<Option<u64>>, | |
| preparing for cloud work | 37 | /// What the badge shows. |
| 38 | pub waiting: Mutex<i64>, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 39 | /// The app's own settings, as the settings window last left them. |
| 40 | pub settings: Mutex<Settings>, | |
| preparing for cloud work | 41 | } |
| 42 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 43 | use crate::settings::Settings; |
| preparing for cloud work | 44 | use crate::updates; |
| 45 | ||
| 46 | #[derive(Default, Serialize, Deserialize)] | |
| 47 | struct File { | |
| 48 | #[serde(default)] | |
| 49 | server: Option<String>, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 50 | #[serde(flatten)] |
| 51 | settings: Settings, | |
| preparing for cloud work | 52 | } |
| 53 | ||
| 54 | fn 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. | |
| 59 | pub 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 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 72 | /// Reads the saved server, or takes g1t.sh, and the saved settings. |
| preparing for cloud work | 73 | pub 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")); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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. | |
| 85 | fn 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 | ||
| 98 | pub fn settings(app: &AppHandle) -> Settings { | |
| 99 | app.state::<State>().settings.lock().unwrap().clone() | |
| 100 | } | |
| 101 | ||
| 102 | /// Keeps new settings. | |
| 103 | pub fn set_settings(app: &AppHandle, settings: Settings) -> Result<(), String> { | |
| 104 | *app.state::<State>().settings.lock().unwrap() = settings; | |
| 105 | save(app) | |
| preparing for cloud work | 106 | } |
| 107 | ||
| 108 | pub 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. | |
| 118 | pub 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()); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 121 | save(app)?; |
| preparing for cloud work | 122 | allow_site(app).map_err(|error| error.to_string())?; |
| 123 | Ok(server) | |
| 124 | } | |
| 125 | ||
| 126 | pub fn origin(url: &Url) -> String { | |
| 127 | url.origin().ascii_serialization() | |
| 128 | } | |
| 129 | ||
| 130 | /// Whether a page is the server's. | |
| 131 | pub fn is_server(app: &AppHandle, url: &Url) -> bool { | |
| 132 | matches!(url.scheme(), "http" | "https") && origin(url) == origin(&server(app)) | |
| 133 | } | |
| 134 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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. | |
| 139 | static DEV_SERVER: std::sync::OnceLock<Option<Url>> = std::sync::OnceLock::new(); | |
| 140 | ||
| 141 | /// Notes the dev server, once, as the app starts. | |
| 142 | pub 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 | ||
| 147 | pub fn dev_server() -> Option<&'static Url> { | |
| 148 | DEV_SERVER.get().and_then(Option::as_ref) | |
| 149 | } | |
| 150 | ||
| preparing for cloud work | 151 | /// Whether a page is the launcher, bundled with the app. |
| 152 | pub fn is_launcher(url: &Url) -> bool { | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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 work | 156 | } |
| 157 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 158 | /// What the server's pages may call: the bridge's commands (bridge.rs). |
| 159 | const 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", | |
| Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it. | 169 | "allow-runner-status", |
| 170 | "allow-runner-offer", | |
| 171 | "allow-runner-register", | |
| 172 | "allow-runner-control", | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 173 | ]; |
| 174 | ||
| 175 | /// What GitHub's pages may call while someone signs in: telling the title bar their title. | |
| 176 | const FRAME: &[&str] = &["allow-title-bar-page"]; | |
| 177 | ||
| preparing for cloud work | 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. | |
| 181 | pub 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(); | |
| A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did. | 185 | if allowed.contains(&origin) { |
| preparing for cloud work | 186 | return Ok(()); |
| 187 | } | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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 | } | |
| preparing for cloud work | 201 | allowed.push(origin); |
| 202 | Ok(()) | |
| 203 | } | |
| 204 | ||
| 205 | /// The window started loading a page. | |
| 206 | pub 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. | |
| 211 | pub 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)] | |
| 216 | mod 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.