| 1 | // The desktop bridge: what the site finds at `window.g1tDesktop`, run at |
| 2 | // the start of every page the window loads (bridge.rs puts it there). The |
| 3 | // site's lib/notify-client.ts describes the contract from its side; when |
| 4 | // the bridge is there, notifications go to the system, the badge to the |
| 5 | // icon, links out to the browser, and g1t:// links arrive as `onNavigate`. |
| 6 | // Each call is a command in bridge.rs, which only answers the server's |
| 7 | // pages; on any other page the calls fail quietly. |
| 8 | (function () { |
| 9 | if (window.g1tDesktop) return; |
| 10 | var VERSION = "__VERSION__"; |
| 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__"; |
| 17 | // The site's pages for reading about g1t, not using it, which open in |
| 18 | // the browser (navigation.rs); one ending in "/" is every page under it. |
| 19 | var OUTSIDE = "__OUTSIDE__"; |
| 20 | |
| 21 | function invoke(command, args) { |
| 22 | var core = window.__TAURI__ && window.__TAURI__.core; |
| 23 | if (core && typeof core.invoke === "function") return core.invoke(command, args || {}); |
| 24 | var internals = window.__TAURI_INTERNALS__; |
| 25 | if (internals && typeof internals.invoke === "function") return internals.invoke(command, args || {}); |
| 26 | return Promise.reject(new Error("g1t desktop: this page cannot reach the app")); |
| 27 | } |
| 28 | |
| 29 | function quietly(promise) { |
| 30 | promise.catch(function (error) { |
| 31 | console.warn("g1t desktop:", error && error.message ? error.message : error); |
| 32 | }); |
| 33 | } |
| 34 | |
| 35 | var listeners = []; |
| 36 | var listening = false; |
| 37 | function listen() { |
| 38 | if (listening) return; |
| 39 | listening = true; |
| 40 | var events = window.__TAURI__ && window.__TAURI__.event; |
| 41 | if (!events || typeof events.listen !== "function") return; |
| 42 | events |
| 43 | .listen("g1t-navigate", function (event) { |
| 44 | var href = event && event.payload; |
| 45 | if (typeof href !== "string") return; |
| 46 | listeners.slice().forEach(function (listener) { |
| 47 | try { |
| 48 | listener(href); |
| 49 | } catch (error) { |
| 50 | console.warn("g1t desktop: a navigate listener failed", error); |
| 51 | } |
| 52 | }); |
| 53 | }) |
| 54 | .catch(function () {}); |
| 55 | } |
| 56 | |
| 57 | var bridge = { |
| 58 | version: VERSION, |
| 59 | platform: PLATFORM, |
| 60 | // A native notification, for a message to the person while the window is behind another. |
| 61 | notify: function (notification) { |
| 62 | quietly(invoke("notify", { title: String(notification.title || "g1t"), body: String(notification.body || "") })); |
| 63 | }, |
| 64 | // What needs the person, on the icon and the tray; 0 clears it. |
| 65 | setBadge: function (count) { |
| 66 | quietly(invoke("set_badge", { count: Math.max(0, Math.floor(Number(count) || 0)) })); |
| 67 | }, |
| 68 | // A link to somewhere else, in the browser the system uses. |
| 69 | openUrl: function (url) { |
| 70 | quietly(invoke("open_url", { url: String(url) })); |
| 71 | }, |
| 72 | // g1t:// links, as site paths such as "/acme/-/chat/dm/chn_1". |
| 73 | onNavigate: function (listener) { |
| 74 | listeners.push(listener); |
| 75 | listen(); |
| 76 | return function () { |
| 77 | var at = listeners.indexOf(listener); |
| 78 | if (at >= 0) listeners.splice(at, 1); |
| 79 | }; |
| 80 | }, |
| 81 | // Looks for an update now: "current", "ready:<version>", or rejects with why. |
| 82 | checkForUpdates: function () { |
| 83 | return invoke("check_for_updates"); |
| 84 | }, |
| 85 | // The app's own settings, in a window of their own. |
| 86 | openSettings: function () { |
| 87 | quietly(invoke("open_settings")); |
| 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, |
| 104 | }; |
| 105 | Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false }); |
| 106 | |
| 107 | // Links out. A link to another site, or to one of the site's pages for |
| 108 | // reading about g1t, opens in the system's browser, and the window stays |
| 109 | // where it is; a link that asks for a new tab, or a click |
| 110 | // with Ctrl or Cmd held, goes on in this window, which has no tabs. |
| 111 | function outside(url) { |
| 112 | var path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : url.pathname; |
| 113 | return OUTSIDE.some(function (page) { |
| 114 | return page.charAt(page.length - 1) === "/" ? path.indexOf(page) === 0 : path === page; |
| 115 | }); |
| 116 | } |
| 117 | function destination(href) { |
| 118 | try { |
| 119 | var url = new URL(href, location.href); |
| 120 | return /^https?:$/.test(url.protocol) ? url : null; |
| 121 | } catch (error) { |
| 122 | return null; |
| 123 | } |
| 124 | } |
| 125 | document.addEventListener( |
| 126 | "click", |
| 127 | function (event) { |
| 128 | if (event.defaultPrevented || event.button !== 0) return; |
| 129 | var anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null; |
| 130 | if (!anchor || anchor.hasAttribute("download")) return; |
| 131 | var url = destination(anchor.getAttribute("href")); |
| 132 | if (!url) return; |
| 133 | if (url.origin !== location.origin || outside(url)) { |
| 134 | event.preventDefault(); |
| 135 | event.stopImmediatePropagation(); |
| 136 | bridge.openUrl(url.href); |
| 137 | } else if (anchor.target === "_blank" || event.metaKey || event.ctrlKey) { |
| 138 | event.preventDefault(); |
| 139 | event.stopImmediatePropagation(); |
| 140 | location.assign(url.href); |
| 141 | } |
| 142 | }, |
| 143 | true, |
| 144 | ); |
| 145 | var open = window.open; |
| 146 | window.open = function (href, target, features) { |
| 147 | var url = destination(href); |
| 148 | if (url) { |
| 149 | if (url.origin === location.origin && !outside(url)) location.assign(url.href); |
| 150 | else bridge.openUrl(url.href); |
| 151 | return null; |
| 152 | } |
| 153 | return typeof open === "function" ? open.call(window, href, target, features) : null; |
| 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 | ); |
| 414 | })(); |