Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
254 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//!
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r93//! 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>>,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r935 /// 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>,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r939 /// The app's own settings, as the settings window last left them.
40 pub settings: Mutex<Settings>,
Merge branch 'main' into rm-agents41 /// The site's page in the window draws the settings and the runner
42 /// menu itself: it said so once it could, and each page that starts
43 /// loading forgets it until it says so again (`site_ready`).
44 pub site_ready: Mutex<bool>,
preparing for cloud work45}
46
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r947use crate::settings::Settings;
preparing for cloud work48use crate::updates;
49
50#[derive(Default, Serialize, Deserialize)]
51struct File {
52 #[serde(default)]
53 server: Option<String>,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r954 #[serde(flatten)]
55 settings: Settings,
preparing for cloud work56}
57
58fn path(app: &AppHandle) -> Option<PathBuf> {
59 app.path().app_config_dir().ok().map(|dir| dir.join("desktop.json"))
60}
61
62/// A server as typed: http or https, with a host; only its origin is kept.
63pub fn parse(text: &str) -> Option<Url> {
64 let text = text.trim();
65 let text = if text.contains("://") { text.to_string() } else { format!("https://{text}") };
66 let mut url = Url::parse(&text).ok()?;
67 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
68 return None;
69 }
70 url.set_path("/");
71 url.set_query(None);
72 url.set_fragment(None);
73 Some(url)
74}
75
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r976/// Reads the saved server, or takes g1t.sh, and the saved settings.
preparing for cloud work77pub fn load(app: &AppHandle) {
78 let file: File = path(app)
79 .and_then(|path| fs::read(path).ok())
80 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
81 .unwrap_or_default();
82 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-7nm8r983 let state = app.state::<State>();
84 *state.server.lock().unwrap() = Some(server);
85 *state.settings.lock().unwrap() = file.settings;
86}
87
88/// Writes the server and the settings as they are now.
89fn save(app: &AppHandle) -> Result<(), String> {
90 let state = app.state::<State>();
91 let file = File {
92 server: state.server.lock().unwrap().as_ref().map(Url::to_string),
93 settings: state.settings.lock().unwrap().clone(),
94 };
95 let path = path(app).ok_or("The app has no folder to keep its settings in.")?;
96 if let Some(dir) = path.parent() {
97 fs::create_dir_all(dir).map_err(|error| error.to_string())?;
98 }
99 fs::write(&path, serde_json::to_vec_pretty(&file).map_err(|error| error.to_string())?).map_err(|error| error.to_string())
100}
101
102pub fn settings(app: &AppHandle) -> Settings {
103 app.state::<State>().settings.lock().unwrap().clone()
104}
105
106/// Keeps new settings.
107pub fn set_settings(app: &AppHandle, settings: Settings) -> Result<(), String> {
108 *app.state::<State>().settings.lock().unwrap() = settings;
109 save(app)
preparing for cloud work110}
111
112pub fn server(app: &AppHandle) -> Url {
113 app.state::<State>()
114 .server
115 .lock()
116 .unwrap()
117 .clone()
118 .unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"))
119}
120
121/// Saves a new server and lets its pages talk to the app.
122pub fn set_server(app: &AppHandle, text: &str) -> Result<Url, String> {
123 let server = parse(text).ok_or("That is not a web address. It looks like https://g1t.example.com.")?;
124 *app.state::<State>().server.lock().unwrap() = Some(server.clone());
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9125 save(app)?;
preparing for cloud work126 allow_site(app).map_err(|error| error.to_string())?;
127 Ok(server)
128}
129
130pub fn origin(url: &Url) -> String {
131 url.origin().ascii_serialization()
132}
133
134/// Whether a page is the server's.
135pub fn is_server(app: &AppHandle, url: &Url) -> bool {
136 matches!(url.scheme(), "http" | "https") && origin(url) == origin(&server(app))
137}
138
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9139/// Where the bundled pages are served under `tauri dev`: with no `devUrl`
140/// in tauri.conf.json, the Tauri CLI serves `launcher/` itself, from
141/// localhost, and the window starts there. Unset in a release build, which
142/// serves them from inside the app.
143static DEV_SERVER: std::sync::OnceLock<Option<Url>> = std::sync::OnceLock::new();
144
145/// Notes the dev server, once, as the app starts.
146pub fn note_dev_server(app: &AppHandle) {
147 let dev = if cfg!(debug_assertions) { app.config().build.dev_url.clone() } else { None };
148 let _ = DEV_SERVER.set(dev);
149}
150
151pub fn dev_server() -> Option<&'static Url> {
152 DEV_SERVER.get().and_then(Option::as_ref)
153}
154
preparing for cloud work155/// Whether a page is the launcher, bundled with the app.
156pub fn is_launcher(url: &Url) -> bool {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9157 url.scheme() == "tauri"
158 || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost"))
159 || dev_server().is_some_and(|dev| origin(dev) == origin(url))
preparing for cloud work160}
161
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9162/// What the server's pages may call: the bridge's commands (bridge.rs).
163const SITE: &[&str] = &[
164 "core:event:allow-listen",
165 "core:event:allow-unlisten",
166 "allow-desktop-info",
167 "allow-notify",
168 "allow-set-badge",
169 "allow-open-url",
170 "allow-check-for-updates",
171 "allow-open-settings",
172 "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.173 "allow-runner-status",
174 "allow-runner-offer",
175 "allow-runner-register",
176 "allow-runner-control",
Merge branch 'main' into rm-agents177 // The settings dialog and the runner menu, which the site draws over itself.
178 "allow-site-ready",
179 "allow-get-settings",
180 "allow-save-settings",
181 "allow-set-open-at-login",
182 "allow-restart",
183 "allow-restart-to-update",
184 "allow-change-server",
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9185];
186
187/// What GitHub's pages may call while someone signs in: telling the title bar their title.
188const FRAME: &[&str] = &["allow-title-bar-page"];
189
preparing for cloud work190/// Lets the server's pages call the app: a capability for its origin,
191/// added once. Pages from anywhere else get no IPC at all, and bridge.rs
192/// checks the caller again on every command.
193pub fn allow_site(app: &AppHandle) -> tauri::Result<()> {
194 let origin = origin(&server(app));
195 let state = app.state::<State>();
196 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.197 if allowed.contains(&origin) {
preparing for cloud work198 return Ok(());
199 }
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9200 let mut site = CapabilityBuilder::new(format!("site-{}", allowed.len())).window(WINDOW).remote(format!("{origin}/*"));
201 for permission in SITE {
202 site = site.permission(*permission);
203 }
204 app.add_capability(site)?;
205 // Signing in with GitHub: its pages tell the title bar what they are, and nothing else.
206 if allowed.is_empty() {
207 let mut sign_in = CapabilityBuilder::new("sign-in").window(WINDOW).remote("https://github.com/*".to_string());
208 for permission in FRAME {
209 sign_in = sign_in.permission(*permission);
210 }
211 app.add_capability(sign_in)?;
212 }
preparing for cloud work213 allowed.push(origin);
214 Ok(())
215}
216
Merge branch 'main' into rm-agents217/// The window started loading a page (`started`), or finished. A page
218/// that starts loading hasn't said it can draw the settings yet.
219pub fn at(app: &AppHandle, url: Url, started: bool) {
220 let state = app.state::<State>();
221 *state.at.lock().unwrap() = Some(url);
222 if started {
223 *state.site_ready.lock().unwrap() = false;
224 }
225}
226
227/// The site's page says whether it draws the settings and the runner menu.
228pub fn set_site_ready(app: &AppHandle, ready: bool) {
229 *app.state::<State>().site_ready.lock().unwrap() = ready;
230}
231
232pub fn site_ready(app: &AppHandle) -> bool {
233 *app.state::<State>().site_ready.lock().unwrap()
preparing for cloud work234}
235
236/// Whether the window is on one of the server's pages now.
237pub fn on_site(app: &AppHandle) -> bool {
238 app.state::<State>().at.lock().unwrap().as_ref().is_some_and(|url| is_server(app, url))
239}
240
241#[cfg(test)]
242mod tests {
243 use super::parse;
244
245 #[test]
246 fn a_server_is_its_origin() {
247 assert_eq!(parse("https://g1t.sh/acme/-/chat?x#y").unwrap().as_str(), "https://g1t.sh/");
248 assert_eq!(parse(" g1t.example.com ").unwrap().as_str(), "https://g1t.example.com/");
249 assert_eq!(parse("http://localhost:5173/login").unwrap().as_str(), "http://localhost:5173/");
250 assert!(parse("ftp://g1t.sh").is_none());
251 assert!(parse("not a url").is_none());
252 assert!(parse("").is_none());
253 }
254}

This file's history is long; its oldest lines are credited to the oldest commit read.