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
| 29 | 29 | user-select: none; | |
| 30 | 30 | cursor: default; | |
| 31 | 31 | } | |
| 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 | + | } | |
| 32 | 39 | main { | |
| 33 | 40 | width: min(26rem, calc(100vw - 2rem)); | |
| 34 | 41 | text-align: center; |
| 51 | 51 | ||
| 52 | 52 | <section aria-labelledby="window-heading"> | |
| 53 | 53 | <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" /> | |
| 57 | 60 | </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> | |
| 58 | 65 | <label class="setting"> | |
| 59 | 66 | <span><strong>Zoom</strong><span class="muted" id="zoom-note">The View menu changes it too.</span></span> | |
| 60 | 67 | <select id="zoom"> |
| 2 | 2 | // the moment it is made. Everything it asks is a command in | |
| 3 | 3 | // src/bridge.rs, answered only to this window. | |
| 4 | 4 | (function () { | |
| 5 | − | var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "menuBar", "autoUpdate"]; | |
| 5 | + | var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"]; | |
| 6 | 6 | var current = null; | |
| 7 | 7 | ||
| 8 | 8 | function byId(id) { | |
| ⋯ | |||
| 33 | 33 | function platform(name) { | |
| 34 | 34 | byId("flash-label").textContent = | |
| 35 | 35 | 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."; | |
| 38 | 38 | if (name === "macos") byId("zoom-note").textContent = "The View menu changes it too: ⌘+, ⌘− and ⌘0."; | |
| 39 | 39 | else byId("zoom-note").textContent = "The View menu changes it too: Ctrl+=, Ctrl+− and Ctrl+0."; | |
| 40 | 40 | } | |
| ⋯ | |||
| 49 | 49 | // Starting in the tray only means something when g1t starts at sign-in. | |
| 50 | 50 | byId("startHidden").disabled = !view.openAtLogin; | |
| 51 | 51 | byId("startHidden").closest(".setting").classList.toggle("off", !view.openAtLogin); | |
| 52 | + | // The title bar changes when g1t restarts. | |
| 53 | + | byId("restart-row").hidden = !view.titleBarChanged; | |
| 52 | 54 | var zoom = byId("zoom"); | |
| 53 | 55 | zoom.value = String(view.zoom); | |
| 54 | 56 | if (zoom.value === "") zoom.value = "1"; | |
| ⋯ | |||
| 156 | 158 | .catch(function () {}); | |
| 157 | 159 | } | |
| 158 | 160 | ||
| 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 | + | ||
| 159 | 169 | byId("change-server").addEventListener("click", function () { | |
| 160 | 170 | invoke("change_server").catch(fail); | |
| 161 | 171 | }); | |
| 1 | 1 | # Built by cargo and the Tauri CLI. | |
| 2 | 2 | /target/ | |
| 3 | 3 | /gen/schemas/ | |
| 4 | + | # Written by build.rs, from the list of the app's commands, at every build. | |
| 5 | + | /permissions/autogenerated/ |
| 1249 | 1249 | name = "g1t-desktop" | |
| 1250 | 1250 | version = "0.1.0" | |
| 1251 | 1251 | dependencies = [ | |
| 1252 | + | "gtk", | |
| 1252 | 1253 | "reqwest 0.12.28", | |
| 1253 | 1254 | "serde", | |
| 1254 | 1255 | "serde_json", |
| 38 | 38 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } | |
| 39 | 39 | tokio = { version = "1", features = ["time"] } | |
| 40 | 40 | ||
| 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 | + | ||
| 41 | 46 | [profile.release] | |
| 42 | 47 | strip = "debuginfo" | |
| 43 | 48 | lto = "thin" |
| 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. | |
| 1 | 6 | 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"); | |
| 3 | 26 | } |
| 3 | 3 | "identifier": "launcher", | |
| 4 | 4 | "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).", | |
| 5 | 5 | "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 | + | ] | |
| 7 | 17 | } |
| 3 | 3 | "identifier": "settings", | |
| 4 | 4 | "description": "The settings window, a page bundled with the app (launcher/settings.html). Its commands answer only this window (src/bridge.rs).", | |
| 5 | 5 | "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 | + | ] | |
| 7 | 18 | } |
| 9 | 9 | if (window.g1tDesktop) return; | |
| 10 | 10 | var VERSION = "__VERSION__"; | |
| 11 | 11 | 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__"; | |
| 12 | 17 | // The site's pages for reading about g1t, not using it, which open in | |
| 13 | 18 | // the browser (navigation.rs); one ending in "/" is every page under it. | |
| 14 | 19 | var OUTSIDE = "__OUTSIDE__"; | |
| ⋯ | |||
| 81 | 86 | openSettings: function () { | |
| 82 | 87 | quietly(invoke("open_settings")); | |
| 83 | 88 | }, | |
| 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, | |
| 84 | 104 | }; | |
| 85 | 105 | Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false }); | |
| 86 | 106 | ||
| ⋯ | |||
| 132 | 152 | } | |
| 133 | 153 | return typeof open === "function" ? open.call(window, href, target, features) : null; | |
| 134 | 154 | }; | |
| 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 | + | ); | |
| 135 | 414 | })(); | |
| 17 | 17 | ||
| 18 | 18 | const SCRIPT: &str = include_str!("bridge.js"); | |
| 19 | 19 | ||
| 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 { | |
| 22 | 23 | SCRIPT | |
| 24 | + | .replace("__WINDOW__", window) | |
| 25 | + | .replace("__FRAME__", if custom { "custom" } else { "system" }) | |
| 26 | + | .replace("\"__HEIGHT__\"", &crate::frame::HEIGHT.to_string()) | |
| 23 | 27 | .replace("__VERSION__", env!("CARGO_PKG_VERSION")) | |
| 24 | 28 | .replace("__PLATFORM__", std::env::consts::OS) | |
| 25 | 29 | .replace("\"__OUTSIDE__\"", &serde_json::to_string(crate::navigation::OUTSIDE).expect("a list of paths is JSON")) | |
| ⋯ | |||
| 61 | 65 | from_site(app, webview).or_else(|_| from_launcher(webview)) | |
| 62 | 66 | } | |
| 63 | 67 | ||
| 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 | + | ||
| 64 | 79 | #[tauri::command] | |
| 65 | 80 | pub fn desktop_info(app: AppHandle, webview: Webview) -> Result<Info, String> { | |
| 66 | 81 | from_either(&app, &webview)?; | |
| ⋯ | |||
| 139 | 154 | updates::check(&app, webview.label() != settings::SETTINGS).await | |
| 140 | 155 | } | |
| 141 | 156 | ||
| 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 | + | ||
| 142 | 185 | /// The settings window's "Restart now": installs the downloaded update and starts it. | |
| 143 | 186 | #[tauri::command] | |
| 144 | 187 | pub fn restart_to_update(app: AppHandle, webview: Webview) -> Result<(), String> { | |
| ⋯ | |||
| 193 | 236 | mod tests { | |
| 194 | 237 | #[test] | |
| 195 | 238 | 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";"#)); | |
| 198 | 245 | assert!(script.contains(r#"var OUTSIDE = ["/pricing""#)); | |
| 199 | 246 | } | |
| 200 | 247 | } | |
| 137 | 137 | url.scheme() == "tauri" || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost")) | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 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 | + | ||
| 140 | 157 | /// Lets the server's pages call the app: a capability for its origin, | |
| 141 | 158 | /// added once. Pages from anywhere else get no IPC at all, and bridge.rs | |
| 142 | 159 | /// checks the caller again on every command. | |
| ⋯ | |||
| 147 | 164 | if allowed.iter().any(|known| *known == origin) { | |
| 148 | 165 | return Ok(()); | |
| 149 | 166 | } | |
| 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 | + | } | |
| 157 | 180 | allowed.push(origin); | |
| 158 | 181 | Ok(()) | |
| 159 | 182 | } | |
| 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 | + | } |
| 15 | 15 | mod badge; | |
| 16 | 16 | mod bridge; | |
| 17 | 17 | mod config; | |
| 18 | + | mod frame; | |
| 18 | 19 | mod links; | |
| 19 | 20 | mod menu; | |
| 20 | 21 | mod navigation; | |
| ⋯ | |||
| 58 | 59 | bridge::set_open_at_login, | |
| 59 | 60 | bridge::change_server, | |
| 60 | 61 | bridge::restart_to_update, | |
| 62 | + | bridge::restart, | |
| 63 | + | bridge::window_control, | |
| 64 | + | bridge::window_menu, | |
| 61 | 65 | ]) | |
| 62 | 66 | .on_menu_event(|app, event| menu::handle(app, event.id().as_ref())) | |
| 63 | 67 | .setup(|app| { | |
| ⋯ | |||
| 66 | 70 | config::allow_site(&handle)?; | |
| 67 | 71 | let hidden = settings::starts_hidden(&handle); | |
| 68 | 72 | let prefs = config::settings(&handle); | |
| 73 | + | let custom = frame::started(&handle); | |
| 69 | 74 | let builder = WebviewWindowBuilder::new(app, WINDOW, WebviewUrl::App("index.html".into())) | |
| 70 | 75 | .title("g1t") | |
| 71 | 76 | .inner_size(1280.0, 840.0) | |
| 72 | 77 | .min_inner_size(720.0, 480.0) | |
| 73 | 78 | .visible(false) | |
| 74 | − | .initialization_script(bridge::script()) | |
| 79 | + | .initialization_script(bridge::script(WINDOW, custom)) | |
| 75 | 80 | .on_navigation({ | |
| 76 | 81 | let handle = handle.clone(); | |
| 77 | 82 | move |url| navigation::allowed(&handle, url) | |
| ⋯ | |||
| 85 | 90 | app.set_menu(menu::build(&handle)?)?; | |
| 86 | 91 | #[cfg(not(target_os = "macos"))] | |
| 87 | 92 | let builder = builder.menu(menu::build(&handle)?); | |
| 88 | − | let window = builder.build()?; | |
| 93 | + | let window = frame::style(builder, custom).build()?; | |
| 89 | 94 | // Opened at sign-in, g1t waits in the tray; otherwise the window comes up where it was. | |
| 90 | 95 | let _ = window.restore_state(StateFlags::all() - StateFlags::VISIBLE); | |
| 96 | + | #[cfg(not(target_os = "macos"))] | |
| 97 | + | frame::menu_bar(&window, custom); | |
| 91 | 98 | if !hidden { | |
| 92 | 99 | let _ = window.show(); | |
| 93 | − | } | |
| 94 | − | #[cfg(not(target_os = "macos"))] | |
| 95 | − | if !prefs.menu_bar { | |
| 96 | − | let _ = window.hide_menu(); | |
| 97 | 100 | } | |
| 98 | 101 | if prefs.zoom != 1.0 { | |
| 99 | 102 | let _ = window.set_zoom(prefs.zoom); | |
| 25 | 25 | OUTSIDE.iter().any(|page| if page.ends_with('/') { path.starts_with(page) } else { path == *page }) | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 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 | + | ||
| 28 | 33 | /// Whether the window may load `url`. | |
| 29 | 34 | pub fn allowed(app: &AppHandle, url: &Url) -> bool { | |
| 30 | 35 | match url.scheme() { | |
| ⋯ | |||
| 35 | 40 | if config::is_launcher(url) || (config::is_server(app, url) && !outside(url.path())) { | |
| 36 | 41 | return true; | |
| 37 | 42 | } | |
| 38 | − | if url.host_str().is_some_and(|host| SIGN_IN_HOSTS.contains(&host)) { | |
| 43 | + | if signing_in(url) { | |
| 39 | 44 | return true; | |
| 40 | 45 | } | |
| 41 | 46 | let _ = app.opener().open_url(url.as_str(), None::<&str>); | |
| 1 | 1 | //! 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 | |
| 3 | 3 | //! updates. They live in their own window (`launcher/settings.html`), | |
| 4 | 4 | //! opened from the menu, the tray, or the site's account menu, and are | |
| 5 | 5 | //! kept with the server in `desktop.json` (config.rs). Whether g1t opens | |
| ⋯ | |||
| 36 | 36 | pub flash: bool, | |
| 37 | 37 | /// What needs the person, counted on the icon. | |
| 38 | 38 | 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, | |
| 41 | 42 | /// The site's zoom, 1 for as it is. | |
| 42 | 43 | pub zoom: f64, | |
| 43 | 44 | /// Updates download by themselves; a person can still check from the menu. | |
| ⋯ | |||
| 46 | 47 | ||
| 47 | 48 | impl Default for Settings { | |
| 48 | 49 | 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 } | |
| 50 | 51 | } | |
| 51 | 52 | } | |
| 52 | 53 | ||
| ⋯ | |||
| 71 | 72 | pub update_ready: Option<String>, | |
| 72 | 73 | /// When the app last asked for updates and heard back, in milliseconds since 1970. | |
| 73 | 74 | 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, | |
| 74 | 77 | } | |
| 75 | 78 | ||
| 76 | 79 | pub fn view(app: &AppHandle) -> View { | |
| ⋯ | |||
| 82 | 85 | server: config::server(app).to_string(), | |
| 83 | 86 | update_ready: crate::updates::ready(app), | |
| 84 | 87 | checked_at: crate::updates::checked_at(app), | |
| 88 | + | title_bar_changed: crate::frame::started(app) != crate::frame::custom(app), | |
| 85 | 89 | } | |
| 86 | 90 | } | |
| 87 | 91 | ||
| ⋯ | |||
| 90 | 94 | settings.zoom = Settings::clamp_zoom(settings.zoom); | |
| 91 | 95 | let before = config::settings(app); | |
| 92 | 96 | 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); | |
| 100 | 99 | } | |
| 101 | 100 | if before.badge != settings.badge { | |
| 102 | 101 | crate::badge::refresh(app); | |
| ⋯ | |||
| 156 | 155 | } | |
| 157 | 156 | // A window still loading has no listener yet: the address asks instead. | |
| 158 | 157 | 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())) | |
| 160 | 160 | .title("g1t settings") | |
| 161 | + | .initialization_script(crate::bridge::script(SETTINGS, custom)); | |
| 162 | + | let built = crate::frame::style(builder, custom) | |
| 161 | 163 | .inner_size(560.0, 680.0) | |
| 162 | 164 | .min_inner_size(440.0, 420.0) | |
| 163 | 165 | .maximizable(false) | |
| 75 | 75 | The tray menu also holds **Settings…**, **Check for updates…**, **Use | |
| 76 | 76 | another g1t…** and, when an update is downloaded, **Restart to update**. | |
| 77 | 77 | ||
| 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 | + | ||
| 78 | 100 | ### The menu | |
| 79 | 101 | ||
| 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. | |
| 82 | 106 | ||
| 83 | 107 | | Menu | What is in it | | |
| 84 | 108 | | --- | --- | | |
| ⋯ | |||
| 89 | 113 | | **Window** | Minimize and maximize | | |
| 90 | 114 | | **Help** | **Desktop app guide** and **g1t.sh status**, in your browser; **Check for updates…**; **Restart to update**, once one is downloaded; **About g1t** | | |
| 91 | 115 | ||
| 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. | |
| 95 | 116 | ||
| 96 | 117 | ## Settings | |
| 97 | 118 | ||
| ⋯ | |||
| 109 | 130 | | **Show notifications** | On | Notifications from your system. Off, nothing pops up from the app, whatever your account's settings say. | | |
| 110 | 131 | | **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. | | |
| 111 | 132 | | **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. | | |
| 113 | 134 | | **Zoom** | 100% | How large g1t is drawn, from 67% to 200%. The View menu changes it too. | | |
| 114 | 135 | | **Download updates by themselves** | On | Off, the app never checks on its own; **Check for updates** still does. | | |
| 115 | 136 | ||
| 372 | 372 | --topbar-h: calc(var(--frame-top) + 3.5rem); | |
| 373 | 373 | --page-h: calc(100dvh - var(--topbar-h) - var(--tabbar-h)); | |
| 374 | 374 | } | |
| 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 | + | } | |
| 375 | 392 | @media (max-width: 767px) { | |
| 376 | 393 | body[data-frame="app"] { | |
| 377 | 394 | --frame-top: env(safe-area-inset-top); |
| 66 | 66 | const params = useParams(); | |
| 67 | 67 | const repo = params.owner && params.repo ? `${params.owner}/${params.repo}` : null; | |
| 68 | 68 | 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"> | |
| 70 | 70 | <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4"> | |
| 71 | 71 | <Link to="/" aria-label="g1t home" className="mr-2 flex"> | |
| 72 | 72 | <Logo /> |
| 7 | 7 | import { InMain } from "./landmark"; | |
| 8 | 8 | import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette"; | |
| 9 | 9 | import { NotificationsBell } from "./inbox"; | |
| 10 | − | import { desktopBridge, useDesktopApp } from "../lib/notify-client"; | |
| 10 | + | import { desktopBridge, useDesktopApp, useTitleBarSearch } from "../lib/notify-client"; | |
| 11 | 11 | import { SpendPill } from "./spend"; | |
| 12 | 12 | import { PinButton } from "./pin-button"; | |
| 13 | 13 | import { Hint } from "./ui/hint"; | |
| ⋯ | |||
| 1998 | 1998 | // A new page closes the drawer. | |
| 1999 | 1999 | useEffect(() => setDrawer(false), [pathname]); | |
| 2000 | 2000 | 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)); | |
| 2001 | 2003 | const leaving = useLeaving(); | |
| 2002 | 2004 | useVisualViewport(); | |
| 2003 | 2005 | const going = useNavigation().location?.pathname; | |
| ⋯ | |||
| 2092 | 2094 | <SidebarProvider open={!closed} onOpenChange={(open) => setClosed(!open)} openMobile={drawer} onOpenMobileChange={setDrawer} toggleSidebar={toggleSidebar}> | |
| 2093 | 2095 | <Progress /> | |
| 2094 | 2096 | {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"> | |
| 2096 | 2098 | <Rail workspace={ws} pins={shell.pins ?? []} unread={unread} account={<AccountMenu user={user} />} /> | |
| 2097 | 2099 | </div> | |
| 2098 | 2100 | )} | |
| ⋯ | |||
| 2100 | 2102 | <Sidebar | |
| 2101 | 2103 | role="complementary" | |
| 2102 | 2104 | 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"}`} | |
| 2104 | 2106 | > | |
| 2105 | 2107 | {sidebarTop(false)} | |
| 2106 | 2108 | <div className="min-h-0 grow">{sidebarNode()}</div> | |
| ⋯ | |||
| 2142 | 2144 | {/* Search, then what's yours (spend, Ask g1t), then the bell and Create new: one height, one rhythm. */} | |
| 2143 | 2145 | <div className="ml-auto flex shrink-0 items-center gap-1 md:gap-1.5"> | |
| 2144 | 2146 | {/* 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 | + | )} | |
| 2148 | 2152 | {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */} | |
| 2149 | 2153 | {ws && ( | |
| 2150 | 2154 | <span className="flex max-md:hidden"> | |
| 19 | 19 | const terms = POLICIES.find((policy) => policy.slug === "terms"); | |
| 20 | 20 | const privacy = POLICIES.find((policy) => policy.slug === "privacy"); | |
| 21 | 21 | 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)]"> | |
| 23 | 23 | <Progress /> | |
| 24 | 24 | <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}`}> | |
| 25 | 25 | <Link to="/" aria-label="g1t" className="flex rounded-md p-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"> |
| 17 | 17 | * workspace shows (`presence`) and your own (`me`), and `setPresence` | |
| 18 | 18 | * changes yours (lib/presence.ts, components/presence.tsx). | |
| 19 | 19 | */ | |
| 20 | − | import { useSyncExternalStore } from "react"; | |
| 20 | + | import { useEffect, useRef, useState, useSyncExternalStore } from "react"; | |
| 21 | 21 | ||
| 22 | 22 | import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts"; | |
| 23 | 23 | ||
| ⋯ | |||
| 71 | 71 | * // Opens the app's own settings (starting with the computer, closing to | |
| 72 | 72 | * // the tray, notifications, the badge, zoom, updates) in their window. | |
| 73 | 73 | * 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, | |
| 74 | 80 | * }; | |
| 75 | 81 | * | |
| 76 | 82 | * The app also opens the site's pages for reading about g1t (pricing, | |
| ⋯ | |||
| 88 | 94 | onNavigate?(listener: (href: string) => void): () => void; | |
| 89 | 95 | checkForUpdates?(): Promise<string>; | |
| 90 | 96 | openSettings?(): void; | |
| 97 | + | setSearch?(label: string, open: () => void): () => void; | |
| 98 | + | titleBarHeight?: number; | |
| 91 | 99 | }; | |
| 92 | 100 | ||
| 93 | 101 | declare global { | |
| ⋯ | |||
| 109 | 117 | return useSyncExternalStore(never, () => !!desktopBridge(), () => false); | |
| 110 | 118 | } | |
| 111 | 119 | ||
| 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 | + | ||
| 112 | 141 | // ── Sinks ──────────────────────────────────────────────────────────────── | |
| 113 | 142 | ||
| 114 | 143 | /** Where a live notification goes once it arrives. */ | |
| 443 | 443 | ) : ( | |
| 444 | 444 | <> | |
| 445 | 445 | <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" /> | |
| 446 | 448 | <PublicHeader user={user} /> | |
| 447 | 449 | {banner} | |
| 448 | 450 | <div id="content" tabIndex={-1} {...leaving} className={`grow outline-none ${leaving.className}`}> |