Skip to content

Commit

The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.

On Windows and Linux the system's frame goes: a bar in the colours of g1t's rail holds the menu button, which opens the app's menu, back and forward, search in the middle, which opens the workspace's search, and minimize, maximize and close. On macOS the same bar sits under the window's own traffic lights. bridge.js draws it in a closed shadow root on every page the window shows, GitHub's sign-in included, and sets <html data-titlebar>; the site makes room through --frame-top and a new --titlebar-h, and leaves search out of its top bar while the title bar has it. The settings window has the same bar. The menu's shortcuts still work with its bar hidden; on Linux the hidden menu bar is kept out of GTK's show_all. "Use the system's title bar" in settings brings the system's frame and the menu bar back, from the next start, which the settings window offers. The app's commands now have permissions (build.rs), granted per page: Tauri lets no remote page call an app command otherwise, so every call from g1t.sh (notifications, the badge, links out, checking for updates) was being refused.

Claude-Session:
https://claude.ai/code/session_01MMRPpook51KPMQQRurb5kC
Claudeagent and Claudeagentcommitted Parentce2a3ccBrowse files
23 files+655−540/23 viewed
+7−0
2929 user-select: none;
3030 cursor: default;
3131 }
32+/* Under the app's own title bar (src/frame.rs): the page fills what is below it. */
33+html[data-titlebar] {
34+ padding-top: 0;
35+}
36+html[data-titlebar] body {
37+ padding-top: 36px;
38+}
3239 main {
3340 width: min(26rem, calc(100vw - 2rem));
3441 text-align: center;
+10−3
5151
5252 <section aria-labelledby="window-heading">
5353 <h2 id="window-heading">Window</h2>
54− <label class="setting" id="menu-bar-row">
55− <span><strong>Show the menu bar</strong><span class="muted">File, Edit, View, Go, Window and Help, under the title.</span></span>
56− <input type="checkbox" role="switch" id="menuBar" />
54+ <label class="setting">
55+ <span
56+ ><strong>Use the system's title bar</strong
57+ ><span class="muted" id="system-title-bar-note">In place of g1t's own, with the menu bar under it.</span></span
58+ >
59+ <input type="checkbox" role="switch" id="systemTitleBar" />
5760 </label>
61+ <div class="setting" id="restart-row" hidden>
62+ <span><strong>Restart to change the title bar</strong><span class="muted">g1t opens again where you were.</span></span>
63+ <button type="button" id="restart-app" class="primary">Restart now</button>
64+ </div>
5865 <label class="setting">
5966 <span><strong>Zoom</strong><span class="muted" id="zoom-note">The View menu changes it too.</span></span>
6067 <select id="zoom">
+13−3
22 // the moment it is made. Everything it asks is a command in
33 // src/bridge.rs, answered only to this window.
44 (function () {
5− var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "menuBar", "autoUpdate"];
5+ var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"];
66 var current = null;
77
88 function byId(id) {
3333 function platform(name) {
3434 byId("flash-label").textContent =
3535 name === "macos" ? "Bounce the dock icon" : name === "windows" ? "Flash the taskbar icon" : "Ask for attention";
36− // macOS keeps its menu bar at the top of the screen, always there.
37− byId("menu-bar-row").hidden = name === "macos";
36+ // macOS keeps its menu bar at the top of the screen, whichever title bar the window has.
37+ if (name === "macos") byId("system-title-bar-note").textContent = "In place of g1t's own, which sits under the window's buttons.";
3838 if (name === "macos") byId("zoom-note").textContent = "The View menu changes it too: ⌘+, ⌘− and ⌘0.";
3939 else byId("zoom-note").textContent = "The View menu changes it too: Ctrl+=, Ctrl+− and Ctrl+0.";
4040 }
4949 // Starting in the tray only means something when g1t starts at sign-in.
5050 byId("startHidden").disabled = !view.openAtLogin;
5151 byId("startHidden").closest(".setting").classList.toggle("off", !view.openAtLogin);
52+ // The title bar changes when g1t restarts.
53+ byId("restart-row").hidden = !view.titleBarChanged;
5254 var zoom = byId("zoom");
5355 zoom.value = String(view.zoom);
5456 if (zoom.value === "") zoom.value = "1";
156158 .catch(function () {});
157159 }
158160
161+ byId("restart-app").addEventListener("click", function () {
162+ byId("restart-app").disabled = true;
163+ invoke("restart").catch(function (error) {
164+ byId("restart-app").disabled = false;
165+ fail(error);
166+ });
167+ });
168+
159169 byId("change-server").addEventListener("click", function () {
160170 invoke("change_server").catch(fail);
161171 });
+2−0
11 # Built by cargo and the Tauri CLI.
22 /target/
33 /gen/schemas/
4+# Written by build.rs, from the list of the app's commands, at every build.
5+/permissions/autogenerated/
+1−0
12491249 name = "g1t-desktop"
12501250 version = "0.1.0"
12511251 dependencies = [
1252+ "gtk",
12521253 "reqwest 0.12.28",
12531254 "serde",
12541255 "serde_json",
+5−0
3838 reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
3939 tokio = { version = "1", features = ["time"] }
4040
41+# Keeping the menu bar hidden under g1t's own title bar (src/frame.rs); the
42+# same GTK that Tauri brings.
43+[target.'cfg(target_os = "linux")'.dependencies]
44+gtk = "0.18"
45+
4146 [profile.release]
4247 strip = "debuginfo"
4348 lto = "thin"
+24−1
1+// The app's own commands (src/bridge.rs) each get a permission,
2+// `allow-<command>`, so a page can call only what its capability grants:
3+// the launcher and the settings window theirs (capabilities/), the site
4+// and GitHub's sign-in pages theirs, given at run time (src/config.rs).
5+// Without these, Tauri lets no remote page call an app command at all.
16 fn main() {
2− tauri_build::build()
7+ tauri_build::try_build(tauri_build::Attributes::new().app_manifest(tauri_build::AppManifest::new().commands(&[
8+ "desktop_info",
9+ "notify",
10+ "set_badge",
11+ "open_url",
12+ "reachable",
13+ "set_server",
14+ "check_for_updates",
15+ "open_settings",
16+ "get_settings",
17+ "save_settings",
18+ "set_open_at_login",
19+ "change_server",
20+ "restart_to_update",
21+ "restart",
22+ "window_control",
23+ "window_menu",
24+ ])))
25+ .expect("the app's build could not be prepared");
326 }
+11−1
33 "identifier": "launcher",
44 "description": "The launcher page bundled with the app, which finds the server and goes there. The site's own pages get their capability at run time, for the server that is configured (src/config.rs).",
55 "windows": ["main"],
6− "permissions": ["core:default"]
6+ "permissions": [
7+ "core:default",
8+ "allow-desktop-info",
9+ "allow-reachable",
10+ "allow-set-server",
11+ "allow-open-url",
12+ "allow-check-for-updates",
13+ "allow-open-settings",
14+ "allow-window-control",
15+ "allow-window-menu"
16+ ]
717 }
+12−1
33 "identifier": "settings",
44 "description": "The settings window, a page bundled with the app (launcher/settings.html). Its commands answer only this window (src/bridge.rs).",
55 "windows": ["settings"],
6− "permissions": ["core:default"]
6+ "permissions": [
7+ "core:default",
8+ "allow-get-settings",
9+ "allow-save-settings",
10+ "allow-set-open-at-login",
11+ "allow-change-server",
12+ "allow-check-for-updates",
13+ "allow-restart-to-update",
14+ "allow-restart",
15+ "allow-open-url",
16+ "allow-window-control"
17+ ]
718 }
+279−0
99 if (window.g1tDesktop) return;
1010 var VERSION = "__VERSION__";
1111 var PLATFORM = "__PLATFORM__";
12+ // Which window this is ("main" or "settings"), whether it draws g1t's
13+ // own title bar ("custom") or has the system's, and the bar's height.
14+ var WINDOW = "__WINDOW__";
15+ var FRAME = "__FRAME__";
16+ var HEIGHT = "__HEIGHT__";
1217 // The site's pages for reading about g1t, not using it, which open in
1318 // the browser (navigation.rs); one ending in "/" is every page under it.
1419 var OUTSIDE = "__OUTSIDE__";
8186 openSettings: function () {
8287 quietly(invoke("open_settings"));
8388 },
89+ // The title bar's search, in its middle: the site hands it a label
90+ // ("Search Flagon") and what opens its search. Returns what takes it away.
91+ setSearch: function (label, open) {
92+ var mine = typeof open === "function" ? { label: String(label || "Search"), open: open } : null;
93+ search = mine;
94+ drawSearch();
95+ return function () {
96+ if (search === mine) {
97+ search = null;
98+ drawSearch();
99+ }
100+ };
101+ },
102+ // Whether g1t draws the title bar, and how tall it is: 0 under the system's.
103+ titleBarHeight: FRAME === "custom" ? HEIGHT : 0,
84104 };
85105 Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false });
86106
132152 }
133153 return typeof open === "function" ? open.call(window, href, target, features) : null;
134154 };
155+
156+ // ── The title bar ─────────────────────────────────────────────────────
157+ // g1t's own, in place of the system's (src/frame.rs), on every page the
158+ // window shows: the menu button, back and forward, search in the middle,
159+ // and the window's buttons; on macOS the system's traffic lights sit at
160+ // its left. It is drawn in a closed shadow root, so no page's styles
161+ // reach it, in the colours of the site's rail where the page has them.
162+ // `data-titlebar` on <html> tells the page to make room: the site does
163+ // (`--frame-top`, apps/web/app/app.css); any other page is pushed down.
164+ var search = null;
165+ var drawSearch = function () {};
166+ if (FRAME !== "custom") return;
167+ var MAC = PLATFORM === "macos";
168+ var MAIN = WINDOW === "main";
169+
170+ function whenThere(test, then) {
171+ if (test()) return then();
172+ var watch = new MutationObserver(function () {
173+ if (!test()) return;
174+ watch.disconnect();
175+ then();
176+ });
177+ watch.observe(document, { childList: true, subtree: true });
178+ }
179+
180+ // Room for the bar, before the page paints. With no specificity of its
181+ // own, any rule of the page's for the same thing wins.
182+ var ROOM = ":where(html[data-titlebar]) { padding-top: " + HEIGHT + "px; }";
183+ try {
184+ var sheet = new CSSStyleSheet();
185+ sheet.replaceSync(ROOM);
186+ document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet);
187+ } catch (error) {
188+ whenThere(
189+ function () {
190+ return document.head;
191+ },
192+ function () {
193+ var style = document.createElement("style");
194+ style.textContent = ROOM;
195+ document.head.appendChild(style);
196+ },
197+ );
198+ }
199+ whenThere(
200+ function () {
201+ return document.documentElement;
202+ },
203+ function () {
204+ document.documentElement.setAttribute("data-titlebar", PLATFORM);
205+ },
206+ );
207+
208+ function svg(body) {
209+ return '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
210+ }
211+ // Drawn here, from lines and boxes, as each system draws its own.
212+ var ICONS = {
213+ menu: svg('<line x1="3" y1="4.5" x2="13" y2="4.5"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="11.5" x2="13" y2="11.5"/>'),
214+ back: svg('<polyline points="9.5,3.5 5,8 9.5,12.5"/><line x1="5" y1="8" x2="13" y2="8"/>'),
215+ forward: svg('<polyline points="6.5,3.5 11,8 6.5,12.5"/><line x1="3" y1="8" x2="11" y2="8"/>'),
216+ search: svg('<circle cx="7" cy="7" r="4"/><line x1="10" y1="10" x2="13" y2="13"/>'),
217+ minimize: svg('<line x1="3.5" y1="8" x2="12.5" y2="8"/>'),
218+ maximize: svg('<rect x="3.5" y="3.5" width="9" height="9" rx="1"/>'),
219+ restore: svg('<rect x="3.5" y="5.5" width="7" height="7" rx="1"/><path d="M5.5 5.5V4.5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-1"/>'),
220+ close: svg('<line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/>'),
221+ };
222+
223+ var STYLE =
224+ ":host{position:fixed;top:0;left:0;right:0;height:" + HEIGHT + "px;z-index:2147483646;display:block;}" +
225+ ".bar{box-sizing:border-box;height:100%;display:flex;align-items:center;gap:2px;padding:0 0 0 6px;" +
226+ "background:var(--color-rail,#050507);color:var(--color-muted,#a0a0a8);" +
227+ "font:500 12px/1 system-ui,'Segoe UI',-apple-system,Roboto,sans-serif;user-select:none;-webkit-user-select:none;cursor:default;}" +
228+ ".mac .bar{padding-left:78px;}" +
229+ ".side{display:flex;align-items:center;gap:2px;flex:1 1 0;min-width:0;}" +
230+ ".end{justify-content:flex-end;}" +
231+ ".middle{flex:0 1 560px;min-width:0;display:flex;justify-content:center;}" +
232+ "button{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;color:inherit;}" +
233+ ".icon{width:28px;height:26px;border-radius:6px;}" +
234+ ".icon:hover{background:color-mix(in srgb,var(--color-fg,#ededef) 10%,transparent);color:var(--color-fg,#ededef);}" +
235+ ".icon:disabled{opacity:.35;background:none;color:inherit;}" +
236+ ".search{width:100%;height:24px;gap:8px;padding:0 10px;justify-content:flex-start;border-radius:6px;" +
237+ "background:color-mix(in srgb,var(--color-fg,#ededef) 9%,transparent);" +
238+ "box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--color-fg,#ededef) 8%,transparent);}" +
239+ ".search:hover{background:color-mix(in srgb,var(--color-fg,#ededef) 14%,transparent);color:var(--color-fg,#ededef);}" +
240+ ".search span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}" +
241+ ".title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 8px;}" +
242+ ".caption{width:46px;height:" + HEIGHT + "px;}" +
243+ ".caption:hover{background:color-mix(in srgb,var(--color-fg,#ededef) 10%,transparent);color:var(--color-fg,#ededef);}" +
244+ ".caption.close:hover{background:#c42b1c;color:#fff;}" +
245+ "button:focus-visible{outline:2px solid var(--color-accent,#b6a8ff);outline-offset:-2px;}";
246+
247+ function button(kind, label, onClick, extra) {
248+ var b = document.createElement("button");
249+ b.type = "button";
250+ b.tabIndex = -1;
251+ b.className = kind + (extra ? " " + extra : "");
252+ b.setAttribute("aria-label", label);
253+ b.title = label;
254+ b.addEventListener("click", function (event) {
255+ event.stopPropagation();
256+ onClick(event, b);
257+ });
258+ return b;
259+ }
260+
261+ function control(action) {
262+ return invoke("window_control", { action: action });
263+ }
264+
265+ var host = document.createElement("g1t-titlebar");
266+ var shadow = host.attachShadow({ mode: "closed" });
267+ var frame = document.createElement("div");
268+ frame.className = MAC ? "mac" : "";
269+ frame.style.height = "100%";
270+ var styleEl = document.createElement("style");
271+ styleEl.textContent = STYLE;
272+ var bar = document.createElement("div");
273+ bar.className = "bar";
274+ bar.setAttribute("role", "toolbar");
275+ bar.setAttribute("aria-label", "Window");
276+ var left = document.createElement("div");
277+ left.className = "side";
278+ var middle = document.createElement("div");
279+ middle.className = "middle";
280+ var right = document.createElement("div");
281+ right.className = "side end";
282+ bar.append(left, middle, right);
283+ frame.append(styleEl, bar);
284+ shadow.append(frame);
285+
286+ var back = null;
287+ var forward = null;
288+ if (MAIN) {
289+ if (!MAC) {
290+ left.append(
291+ button("icon", "Menu", function (event, b) {
292+ var box = b.getBoundingClientRect();
293+ quietly(invoke("window_menu", { x: box.left, y: box.bottom }));
294+ }),
295+ );
296+ }
297+ back = button("icon", "Back", function () {
298+ history.back();
299+ });
300+ forward = button("icon", "Forward", function () {
301+ history.forward();
302+ });
303+ back.innerHTML = ICONS.back;
304+ forward.innerHTML = ICONS.forward;
305+ left.append(back, forward);
306+ if (!MAC) left.firstChild.innerHTML = ICONS.menu;
307+ }
308+
309+ // Back and forward, greyed when there is nowhere to go, where the web view can tell.
310+ function drawHistory() {
311+ if (!back) return;
312+ var nav = window.navigation;
313+ if (!nav || typeof nav.canGoBack !== "boolean") return;
314+ back.disabled = !nav.canGoBack;
315+ forward.disabled = !nav.canGoForward;
316+ }
317+ if (window.navigation && typeof window.navigation.addEventListener === "function") {
318+ window.navigation.addEventListener("currententrychange", drawHistory);
319+ }
320+ drawHistory();
321+
322+ // The middle: the site's search when it has handed one over, otherwise the page's title.
323+ var title = document.createElement("span");
324+ title.className = "title";
325+ drawSearch = function () {
326+ middle.textContent = "";
327+ if (search) {
328+ var pill = button("search", search.label, function () {
329+ search.open();
330+ });
331+ pill.innerHTML = ICONS.search;
332+ var text = document.createElement("span");
333+ text.textContent = search.label;
334+ pill.append(text);
335+ middle.append(pill);
336+ } else {
337+ title.textContent = document.title || "g1t";
338+ middle.append(title);
339+ }
340+ };
341+ drawSearch();
342+ {
343+ var retitle = function () {
344+ if (!search) title.textContent = document.title || "g1t";
345+ };
346+ document.addEventListener("DOMContentLoaded", retitle);
347+ whenThere(
348+ function () {
349+ return document.head;
350+ },
351+ function () {
352+ retitle();
353+ new MutationObserver(retitle).observe(document.head, { subtree: true, childList: true, characterData: true });
354+ },
355+ );
356+ }
357+
358+ // The window's buttons, at the right as Windows and Linux put them; macOS has its own.
359+ if (!MAC) {
360+ if (MAIN) {
361+ var minimize = button("caption", "Minimize", function () {
362+ quietly(control("minimize"));
363+ });
364+ minimize.innerHTML = ICONS.minimize;
365+ var maximize = button("caption", "Maximize", function () {
366+ control("maximize").then(drawMaximized, function () {});
367+ });
368+ maximize.innerHTML = ICONS.maximize;
369+ right.append(minimize, maximize);
370+ var drawMaximized = function (maximized) {
371+ maximize.innerHTML = maximized ? ICONS.restore : ICONS.maximize;
372+ maximize.setAttribute("aria-label", maximized ? "Restore" : "Maximize");
373+ maximize.title = maximized ? "Restore" : "Maximize";
374+ };
375+ var resizing = 0;
376+ window.addEventListener("resize", function () {
377+ clearTimeout(resizing);
378+ resizing = setTimeout(function () {
379+ control("maximized").then(drawMaximized, function () {});
380+ }, 120);
381+ });
382+ control("maximized").then(drawMaximized, function () {});
383+ }
384+ var close = button("caption", "Close", function () {
385+ quietly(control("close"));
386+ }, "close");
387+ close.innerHTML = ICONS.close;
388+ right.append(close);
389+ }
390+
391+ // Dragging the bar moves the window; a double click maximizes it, as a system's title bar does.
392+ bar.addEventListener("mousedown", function (event) {
393+ if (event.button !== 0 || event.target.closest("button")) return;
394+ if (event.detail === 2) {
395+ if (MAIN) control("maximize").then(drawMaximized || function () {}, function () {});
396+ return;
397+ }
398+ quietly(control("drag"));
399+ });
400+
401+ // In the page from as early as there is a page, and back if a page's script takes it out.
402+ function attach() {
403+ if (host.parentNode !== document.documentElement) document.documentElement.appendChild(host);
404+ }
405+ whenThere(
406+ function () {
407+ return document.body;
408+ },
409+ function () {
410+ attach();
411+ new MutationObserver(attach).observe(document.documentElement, { childList: true });
412+ },
413+ );
135414 })();
+51−4
1717
1818 const SCRIPT: &str = include_str!("bridge.js");
1919
20−/// The bridge, run at the start of every page the window loads.
21−pub fn script() -> String {
20+/// The bridge, run at the start of every page a window loads: `window` is
21+/// its label, and `custom` whether it draws g1t's title bar (frame.rs).
22+pub fn script(window: &str, custom: bool) -> String {
2223 SCRIPT
24+ .replace("__WINDOW__", window)
25+ .replace("__FRAME__", if custom { "custom" } else { "system" })
26+ .replace("\"__HEIGHT__\"", &crate::frame::HEIGHT.to_string())
2327 .replace("__VERSION__", env!("CARGO_PKG_VERSION"))
2428 .replace("__PLATFORM__", std::env::consts::OS)
2529 .replace("\"__OUTSIDE__\"", &serde_json::to_string(crate::navigation::OUTSIDE).expect("a list of paths is JSON"))
6165 from_site(app, webview).or_else(|_| from_launcher(webview))
6266 }
6367
68+/// The page asking is one the title bar is drawn on: the site's, the
69+/// app's own, or GitHub's while signing in.
70+fn from_frame(app: &AppHandle, webview: &Webview) -> Result<(), String> {
71+ let url = caller(webview)?;
72+ if config::is_server(app, &url) || config::is_launcher(&url) || crate::navigation::signing_in(&url) {
73+ Ok(())
74+ } else {
75+ Err("only g1t's own pages may ask that".into())
76+ }
77+}
78+
6479 #[tauri::command]
6580 pub fn desktop_info(app: AppHandle, webview: Webview) -> Result<Info, String> {
6681 from_either(&app, &webview)?;
139154 updates::check(&app, webview.label() != settings::SETTINGS).await
140155 }
141156
157+/// A title bar button: `minimize`, `maximize` (which toggles), `close`,
158+/// `drag`, or `maximized` to ask. Answers whether the window is maximized.
159+#[tauri::command]
160+pub fn window_control(app: AppHandle, webview: Webview, action: String) -> Result<bool, String> {
161+ from_frame(&app, &webview)?;
162+ let window = app.get_webview_window(webview.label()).ok_or("no such window")?;
163+ crate::frame::control(&app, &window, &action)
164+}
165+
166+/// The title bar's menu button: the app's menu, at that place in the window.
167+#[tauri::command]
168+pub fn window_menu(app: AppHandle, webview: Webview, x: f64, y: f64) -> Result<(), String> {
169+ from_frame(&app, &webview)?;
170+ let window = app.get_webview_window(WINDOW).filter(|_| webview.label() == WINDOW).ok_or("only the main window has a menu")?;
171+ crate::frame::menu(&window, x, y)
172+}
173+
174+/// Restarts g1t, installing a downloaded update on the way: the settings
175+/// window offers it after a change that needs one.
176+#[tauri::command]
177+pub fn restart(app: AppHandle, webview: Webview) -> Result<(), String> {
178+ from_settings(&webview)?;
179+ if updates::ready(&app).is_some() {
180+ updates::restart(&app);
181+ }
182+ app.restart()
183+}
184+
142185 /// The settings window's "Restart now": installs the downloaded update and starts it.
143186 #[tauri::command]
144187 pub fn restart_to_update(app: AppHandle, webview: Webview) -> Result<(), String> {
193236 mod tests {
194237 #[test]
195238 fn the_bridge_has_nothing_left_to_fill_in() {
196− let script = super::script();
197− assert!(!script.contains("__VERSION__") && !script.contains("__PLATFORM__") && !script.contains("__OUTSIDE__"));
239+ let script = super::script("main", true);
240+ for placeholder in ["__VERSION__", "__PLATFORM__", "__OUTSIDE__", "__WINDOW__", "__FRAME__", "__HEIGHT__"] {
241+ assert!(!script.contains(placeholder), "{placeholder} is left in the bridge");
242+ }
243+ assert!(script.contains(r#"var HEIGHT = 36;"#) && script.contains(r#"var FRAME = "custom";"#));
244+ assert!(super::script("settings", false).contains(r#"var FRAME = "system";"#));
198245 assert!(script.contains(r#"var OUTSIDE = ["/pricing""#));
199246 }
200247 }
+30−7
137137 url.scheme() == "tauri" || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost"))
138138 }
139139
140+/// What the server's pages may call: the bridge's commands (bridge.rs).
141+const 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.
155+const FRAME: &[&str] = &["allow-window-control", "allow-window-menu"];
156+
140157 /// Lets the server's pages call the app: a capability for its origin,
141158 /// added once. Pages from anywhere else get no IPC at all, and bridge.rs
142159 /// checks the caller again on every command.
147164 if allowed.iter().any(|known| *known == origin) {
148165 return Ok(());
149166 }
150− app.add_capability(
151− CapabilityBuilder::new(format!("site-{}", allowed.len()))
152− .window(WINDOW)
153− .remote(format!("{origin}/*"))
154− .permission("core:event:allow-listen")
155− .permission("core:event:allow-unlisten"),
156− )?;
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+ }
157180 allowed.push(origin);
158181 Ok(())
159182 }
+93−0
1+//! The window's frame. g1t draws its own title bar, as Slack and Discord
2+//! do, so the window is all g1t and looks the same on every system: on
3+//! Windows and Linux the system's frame goes and the bar has the menu
4+//! button, back and forward, search, and minimize, maximize and close; on
5+//! macOS the bar sits under the window's own traffic lights. The bar
6+//! itself is bridge.js's, drawn on every page the window shows, the
7+//! site's, the launcher's, and GitHub's while signing in; the site makes
8+//! room for it (`--frame-top`, apps/web/app/app.css). Settings can give
9+//! the window back its system frame, and the menu bar with it, from the
10+//! next start: the bar is a script each window gets as it opens.
11+
12+use tauri::{AppHandle, LogicalPosition, Manager, Runtime, WebviewWindow, WebviewWindowBuilder};
13+
14+use crate::{WINDOW, config, tray};
15+
16+/// The bar's height, in CSS pixels; bridge.js draws it this tall.
17+pub const HEIGHT: f64 = 36.0;
18+
19+/// Whether g1t draws its own title bar, as the settings say now.
20+pub fn custom(app: &AppHandle) -> bool {
21+ !config::settings(app).system_title_bar
22+}
23+
24+/// The title bar g1t started with, which its windows keep until it restarts.
25+static STARTED_CUSTOM: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
26+
27+/// Whether this run draws g1t's own title bar: decided once, at the start.
28+pub fn started(app: &AppHandle) -> bool {
29+ *STARTED_CUSTOM.get_or_init(|| custom(app))
30+}
31+
32+/// A window with g1t's title bar, or the system's.
33+pub fn style<'a, R: Runtime, M: Manager<R>>(builder: WebviewWindowBuilder<'a, R, M>, custom: bool) -> WebviewWindowBuilder<'a, R, M> {
34+ if !custom {
35+ return builder;
36+ }
37+ #[cfg(target_os = "macos")]
38+ {
39+ // The traffic lights stay, over the bar and centred in it.
40+ builder
41+ .title_bar_style(tauri::TitleBarStyle::Overlay)
42+ .hidden_title(true)
43+ .traffic_light_position(LogicalPosition::new(14.0, (HEIGHT - 14.0) / 2.0))
44+ }
45+ #[cfg(not(target_os = "macos"))]
46+ {
47+ builder.decorations(false)
48+ }
49+}
50+
51+/// The main window's menu bar: shown under the system's frame, and kept
52+/// hidden under g1t's, whose menu button opens the same menu (`menu`) and
53+/// whose shortcuts still work.
54+pub fn menu_bar(window: &WebviewWindow, custom: bool) {
55+ let _ = if custom { window.hide_menu() } else { window.show_menu() };
56+ // GTK shows everything in a window each time the window is shown, the
57+ // hidden menu bar too, unless the menu bar is told to sit that out.
58+ #[cfg(target_os = "linux")]
59+ if let Ok(vbox) = window.default_vbox() {
60+ use gtk::prelude::*;
61+ for child in vbox.children().into_iter().filter(|child| child.is::<gtk::MenuBar>()) {
62+ child.set_no_show_all(custom);
63+ }
64+ }
65+}
66+
67+/// What a button on the bar asks of the window it is in.
68+pub fn control(app: &AppHandle, window: &WebviewWindow, action: &str) -> Result<bool, String> {
69+ let main = window.label() == WINDOW;
70+ match action {
71+ "minimize" => window.minimize().map_err(|error| error.to_string())?,
72+ "maximize" => {
73+ let maximized = window.is_maximized().unwrap_or(false);
74+ let _ = if maximized { window.unmaximize() } else { window.maximize() };
75+ }
76+ "close" if main => tray::close(app),
77+ "close" => window.close().map_err(|error| error.to_string())?,
78+ "drag" => window.start_dragging().map_err(|error| error.to_string())?,
79+ "maximized" => {}
80+ other => return Err(format!("the window cannot {other}")),
81+ }
82+ Ok(window.is_maximized().unwrap_or(false))
83+}
84+
85+/// The menu button: the window's menu, under the button.
86+pub fn menu(window: &WebviewWindow, x: f64, y: f64) -> Result<(), String> {
87+ #[cfg(target_os = "macos")]
88+ let menu = window.app_handle().menu();
89+ #[cfg(not(target_os = "macos"))]
90+ let menu = window.menu();
91+ let menu = menu.ok_or("the window has no menu")?;
92+ window.popup_menu_at(&menu, LogicalPosition::new(x, y)).map_err(|error| error.to_string())
93+}
+9−6
1515 mod badge;
1616 mod bridge;
1717 mod config;
18+mod frame;
1819 mod links;
1920 mod menu;
2021 mod navigation;
5859 bridge::set_open_at_login,
5960 bridge::change_server,
6061 bridge::restart_to_update,
62+ bridge::restart,
63+ bridge::window_control,
64+ bridge::window_menu,
6165 ])
6266 .on_menu_event(|app, event| menu::handle(app, event.id().as_ref()))
6367 .setup(|app| {
6670 config::allow_site(&handle)?;
6771 let hidden = settings::starts_hidden(&handle);
6872 let prefs = config::settings(&handle);
73+ let custom = frame::started(&handle);
6974 let builder = WebviewWindowBuilder::new(app, WINDOW, WebviewUrl::App("index.html".into()))
7075 .title("g1t")
7176 .inner_size(1280.0, 840.0)
7277 .min_inner_size(720.0, 480.0)
7378 .visible(false)
74− .initialization_script(bridge::script())
79+ .initialization_script(bridge::script(WINDOW, custom))
7580 .on_navigation({
7681 let handle = handle.clone();
7782 move |url| navigation::allowed(&handle, url)
8590 app.set_menu(menu::build(&handle)?)?;
8691 #[cfg(not(target_os = "macos"))]
8792 let builder = builder.menu(menu::build(&handle)?);
88− let window = builder.build()?;
93+ let window = frame::style(builder, custom).build()?;
8994 // Opened at sign-in, g1t waits in the tray; otherwise the window comes up where it was.
9095 let _ = window.restore_state(StateFlags::all() - StateFlags::VISIBLE);
96+ #[cfg(not(target_os = "macos"))]
97+ frame::menu_bar(&window, custom);
9198 if !hidden {
9299 let _ = window.show();
93− }
94− #[cfg(not(target_os = "macos"))]
95− if !prefs.menu_bar {
96− let _ = window.hide_menu();
97100 }
98101 if prefs.zoom != 1.0 {
99102 let _ = window.set_zoom(prefs.zoom);
+6−1
2525 OUTSIDE.iter().any(|page| if page.ends_with('/') { path.starts_with(page) } else { path == *page })
2626 }
2727
28+/// Whether a page is one of signing in with GitHub's.
29+pub fn signing_in(url: &Url) -> bool {
30+ url.scheme() == "https" && url.host_str().is_some_and(|host| SIGN_IN_HOSTS.contains(&host))
31+}
32+
2833 /// Whether the window may load `url`.
2934 pub fn allowed(app: &AppHandle, url: &Url) -> bool {
3035 match url.scheme() {
3540 if config::is_launcher(url) || (config::is_server(app, url) && !outside(url.path())) {
3641 return true;
3742 }
38− if url.host_str().is_some_and(|host| SIGN_IN_HOSTS.contains(&host)) {
43+ if signing_in(url) {
3944 return true;
4045 }
4146 let _ = app.opener().open_url(url.as_str(), None::<&str>);
+14−12
11 //! The app's own settings: how it starts, what closing the window does,
2−//! how it tells the person something arrived, the menu bar, zoom and
2+//! how it tells the person something arrived, its title bar, zoom and
33 //! updates. They live in their own window (`launcher/settings.html`),
44 //! opened from the menu, the tray, or the site's account menu, and are
55 //! kept with the server in `desktop.json` (config.rs). Whether g1t opens
3636 pub flash: bool,
3737 /// What needs the person, counted on the icon.
3838 pub badge: bool,
39− /// The menu bar under the title (Windows and Linux; macOS has its own at the top).
40− pub menu_bar: bool,
39+ /// The system's title bar, and on Windows and Linux its menu bar, in
40+ /// place of g1t's own (frame.rs). Takes effect when g1t restarts.
41+ pub system_title_bar: bool,
4142 /// The site's zoom, 1 for as it is.
4243 pub zoom: f64,
4344 /// Updates download by themselves; a person can still check from the menu.
4647
4748 impl Default for Settings {
4849 fn default() -> Self {
49− Self { start_hidden: true, close_to_tray: true, notifications: true, flash: true, badge: true, menu_bar: true, zoom: 1.0, auto_update: true }
50+ Self { start_hidden: true, close_to_tray: true, notifications: true, flash: true, badge: true, system_title_bar: false, zoom: 1.0, auto_update: true }
5051 }
5152 }
5253
7172 pub update_ready: Option<String>,
7273 /// When the app last asked for updates and heard back, in milliseconds since 1970.
7374 pub checked_at: Option<u64>,
75+ /// The title bar setting differs from the one the windows have: a restart applies it.
76+ pub title_bar_changed: bool,
7477 }
7578
7679 pub fn view(app: &AppHandle) -> View {
8285 server: config::server(app).to_string(),
8386 update_ready: crate::updates::ready(app),
8487 checked_at: crate::updates::checked_at(app),
88+ title_bar_changed: crate::frame::started(app) != crate::frame::custom(app),
8589 }
8690 }
8791
9094 settings.zoom = Settings::clamp_zoom(settings.zoom);
9195 let before = config::settings(app);
9296 config::set_settings(app, settings.clone())?;
93− if let Some(window) = app.get_webview_window(WINDOW) {
94− if before.menu_bar != settings.menu_bar {
95− let _ = if settings.menu_bar { window.show_menu() } else { window.hide_menu() };
96− }
97− if before.zoom != settings.zoom {
98− let _ = window.set_zoom(settings.zoom);
99− }
97+ if let Some(window) = app.get_webview_window(WINDOW).filter(|_| before.zoom != settings.zoom) {
98+ let _ = window.set_zoom(settings.zoom);
10099 }
101100 if before.badge != settings.badge {
102101 crate::badge::refresh(app);
156155 }
157156 // A window still loading has no listener yet: the address asks instead.
158157 let page = if check { "settings.html?check" } else { "settings.html" };
159− let built = WebviewWindowBuilder::new(app, SETTINGS, WebviewUrl::App(page.into()))
158+ let custom = crate::frame::started(app);
159+ let builder = WebviewWindowBuilder::new(app, SETTINGS, WebviewUrl::App(page.into()))
160160 .title("g1t settings")
161+ .initialization_script(crate::bridge::script(SETTINGS, custom));
162+ let built = crate::frame::style(builder, custom)
161163 .inner_size(560.0, 680.0)
162164 .min_inner_size(440.0, 420.0)
163165 .maximizable(false)
+27−6
7575 The tray menu also holds **Settings…**, **Check for updates…**, **Use
7676 another g1t…** and, when an update is downloaded, **Restart to update**.
7777
78+### The title bar
79+
80+g1t draws its own title bar, as Slack and Discord do, so the whole window
81+is g1t and looks the same on Windows, macOS and Linux. It takes the
82+colours of g1t's rail, in light and dark.
83+
84+| Part | What it does |
85+| --- | --- |
86+| **☰** (Windows and Linux) | Opens the app's [menu](#the-menu). |
87+| **← →** | Back and forward through the pages you have been on. |
88+| **Search** | In the middle: searches your workspace, as **Ctrl+K** or **⌘K** does. Before you are signed in, it shows the page's title. |
89+| **Minimize, maximize, close** (Windows and Linux) | At the right. Close keeps g1t in the tray unless [the settings](#settings) say it quits. On macOS the window's own buttons sit at the left of the bar. |
90+
91+Drag the bar to move the window, and double-click it to maximize or
92+restore it. The bar is there on every page the window shows, signing in
93+with GitHub included.
94+
95+To have the system's title bar instead, turn on **Use the system's title
96+bar** in [the settings](#settings). On Windows and Linux the menu bar
97+comes back under it. g1t restarts to change it: the settings window offers
98+**Restart now**.
99+
78100 ### The menu
79101
80−The app has the menus a desktop app has: under the window's title on
81−Windows and Linux, at the top of the screen on macOS.
102+The app has the menus a desktop app has: under **☰** at the left of the
103+title bar on Windows and Linux, or under the system's title bar if you
104+use it, and at the top of the screen on macOS. Their shortcuts work
105+either way.
82106
83107 | Menu | What is in it |
84108 | --- | --- |
89113 | **Window** | Minimize and maximize |
90114 | **Help** | **Desktop app guide** and **g1t.sh status**, in your browser; **Check for updates…**; **Restart to update**, once one is downloaded; **About g1t** |
91115
92−On Windows and Linux, **Show the menu bar** in the settings hides it; the
93−tray's **Settings…** and your account menu still open the settings to bring
94−it back.
95116
96117 ## Settings
97118
109130 | **Show notifications** | On | Notifications from your system. Off, nothing pops up from the app, whatever your account's settings say. |
110131 | **Flash the taskbar icon** (**Bounce the dock icon** on macOS) | On | Asks for your attention with a notification while the window is not in front. |
111132 | **Show a badge on the icon** | On | The count, or the dot on Windows. Off, the icon stays clean; the tray's hover text still counts. |
112−| **Show the menu bar** | On | Windows and Linux only. |
133+| **Use the system's title bar** | Off | The system's title bar in place of [g1t's own](#the-title-bar), with the menu bar under it on Windows and Linux. Takes effect when g1t restarts. |
113134 | **Zoom** | 100% | How large g1t is drawn, from 67% to 200%. The View menu changes it too. |
114135 | **Download updates by themselves** | On | Off, the app never checks on its own; **Check for updates** still does. |
115136
+17−0
372372 --topbar-h: calc(var(--frame-top) + 3.5rem);
373373 --page-h: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
374374 }
375+/*
376+ * The desktop app's own title bar (apps/desktop, src/frame.rs), drawn over
377+ * the top of the window on <html data-titlebar>: what is above the page is
378+ * the bar, whatever the frame. --titlebar-h is for frames that do not
379+ * read --frame-top (the public header, the standalone frame). The bar
380+ * pushes any page down by itself; g1t's pages make room their own way.
381+ */
382+:root {
383+ --titlebar-h: 0px;
384+}
385+html[data-titlebar] {
386+ --titlebar-h: 36px;
387+ padding-top: 0;
388+}
389+html[data-titlebar] body {
390+ --frame-top: var(--titlebar-h);
391+}
375392 @media (max-width: 767px) {
376393 body[data-frame="app"] {
377394 --frame-top: env(safe-area-inset-top);
+1−1
6666 const params = useParams();
6767 const repo = params.owner && params.repo ? `${params.owner}/${params.repo}` : null;
6868 return (
69− <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur">
69+ <header className="sticky top-(--titlebar-h) z-40 border-b border-line bg-surface/85 backdrop-blur">
7070 <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4">
7171 <Link to="/" aria-label="g1t home" className="mr-2 flex">
7272 <Logo />
+10−6
77 import { InMain } from "./landmark";
88 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
99 import { NotificationsBell } from "./inbox";
10−import { desktopBridge, useDesktopApp } from "../lib/notify-client";
10+import { desktopBridge, useDesktopApp, useTitleBarSearch } from "../lib/notify-client";
1111 import { SpendPill } from "./spend";
1212 import { PinButton } from "./pin-button";
1313 import { Hint } from "./ui/hint";
19981998 // A new page closes the drawer.
19991999 useEffect(() => setDrawer(false), [pathname]);
20002000 usePaletteShortcut(() => setPalette((open) => !open));
2001+ // In the desktop app, search is in the middle of its title bar, as in Slack, and not in the top bar too.
2002+ const titleSearch = useTitleBarSearch(`Search ${shell.workspace?.name ?? shell.workspace?.slug ?? "g1t"}`, () => setPalette(true));
20012003 const leaving = useLeaving();
20022004 useVisualViewport();
20032005 const going = useNavigation().location?.pathname;
20922094 <SidebarProvider open={!closed} onOpenChange={(open) => setClosed(!open)} openMobile={drawer} onOpenMobileChange={setDrawer} toggleSidebar={toggleSidebar}>
20932095 <Progress />
20942096 {ws && (
2095− <div className="fixed inset-y-0 left-0 z-40 hidden md:block">
2097+ <div className="fixed top-(--frame-top) bottom-0 left-0 z-40 hidden md:block">
20962098 <Rail workspace={ws} pins={shell.pins ?? []} unread={unread} account={<AccountMenu user={user} />} />
20972099 </div>
20982100 )}
21002102 <Sidebar
21012103 role="complementary"
21022104 aria-label={`${MODE_MENU[panel]} sidebar`}
2103− className={`fixed inset-y-0 z-40 max-lg:hidden ${ws ? "left-14" : "left-0"}`}
2105+ className={`fixed top-(--frame-top) bottom-0 z-40 max-lg:hidden ${ws ? "left-14" : "left-0"}`}
21042106 >
21052107 {sidebarTop(false)}
21062108 <div className="min-h-0 grow">{sidebarNode()}</div>
21422144 {/* Search, then what's yours (spend, Ask g1t), then the bell and Create new: one height, one rhythm. */}
21432145 <div className="ml-auto flex shrink-0 items-center gap-1 md:gap-1.5">
21442146 {/* With the sidebar open, search is at its top. */}
2145− <span className={`flex ${inline && sidebarSearch ? "lg:hidden" : ""}`}>
2146− <SearchButton place="bar" onClick={() => setPalette(true)} />
2147− </span>
2147+ {!titleSearch && (
2148+ <span className={`flex ${inline && sidebarSearch ? "lg:hidden" : ""}`}>
2149+ <SearchButton place="bar" onClick={() => setPalette(true)} />
2150+ </span>
2151+ )}
21482152 {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */}
21492153 {ws && (
21502154 <span className="flex max-md:hidden">
+1−1
1919 const terms = POLICIES.find((policy) => policy.slug === "terms");
2020 const privacy = POLICIES.find((policy) => policy.slug === "privacy");
2121 return (
22− <div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
22+ <div className="flex min-h-dvh flex-col pt-[max(env(safe-area-inset-top),var(--titlebar-h))] pb-[env(safe-area-inset-bottom)]">
2323 <Progress />
2424 <div id="content" tabIndex={-1} {...leaving} className={`flex grow flex-col items-center px-4 pt-[max(2.5rem,9vh)] pb-10 outline-none ${leaving.className}`}>
2525 <Link to="/" aria-label="g1t" className="flex rounded-md p-1 outline-none focus-visible:ring-2 focus-visible:ring-accent">
+30−1
1717 * workspace shows (`presence`) and your own (`me`), and `setPresence`
1818 * changes yours (lib/presence.ts, components/presence.tsx).
1919 */
20−import { useSyncExternalStore } from "react";
20+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
2121
2222 import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts";
2323
7171 * // Opens the app's own settings (starting with the computer, closing to
7272 * // the tray, notifications, the badge, zoom, updates) in their window.
7373 * openSettings() {},
74+ * // The search in the middle of the app's own title bar: its label, and
75+ * // what it opens. Returns what takes it away.
76+ * setSearch(label, open) { return () => {} },
77+ * // How tall the app's own title bar is, 0 under the system's. The page
78+ * // also finds it as <html data-titlebar> and --titlebar-h (app.css).
79+ * titleBarHeight: 36,
7480 * };
7581 *
7682 * The app also opens the site's pages for reading about g1t (pricing,
8894 onNavigate?(listener: (href: string) => void): () => void;
8995 checkForUpdates?(): Promise<string>;
9096 openSettings?(): void;
97+ setSearch?(label: string, open: () => void): () => void;
98+ titleBarHeight?: number;
9199 };
92100
93101 declare global {
109117 return useSyncExternalStore(never, () => !!desktopBridge(), () => false);
110118 }
111119
120+/**
121+ * Puts search in the desktop app's title bar while mounted. True when the
122+ * bar has it, so the page can leave out its own search button.
123+ */
124+export function useTitleBarSearch(label: string, open: () => void): boolean {
125+ const [shown, setShown] = useState(false);
126+ const opener = useRef(open);
127+ opener.current = open;
128+ useEffect(() => {
129+ const bridge = desktopBridge();
130+ if (!bridge?.setSearch || !bridge.titleBarHeight) return;
131+ const remove = bridge.setSearch(label, () => opener.current());
132+ setShown(true);
133+ return () => {
134+ remove();
135+ setShown(false);
136+ };
137+ }, [label]);
138+ return shown;
139+}
140+
112141 // ── Sinks ────────────────────────────────────────────────────────────────
113142
114143 /** Where a live notification goes once it arrives. */
+2−0
443443 ) : (
444444 <>
445445 <Progress />
446+ {/* Under the desktop app's title bar, the header starts below it. */}
447+ <div aria-hidden="true" className="h-(--titlebar-h) shrink-0" />
446448 <PublicHeader user={user} />
447449 {banner}
448450 <div id="content" tabIndex={-1} {...leaving} className={`grow outline-none ${leaving.className}`}>