Skip to content
414 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

preparing for cloud work1// 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__";
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.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__";
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.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__";
preparing for cloud work20
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 },
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.85 // The app's own settings, in a window of their own.
86 openSettings: function () {
87 quietly(invoke("open_settings"));
88 },
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.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,
preparing for cloud work104 };
105 Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false });
106
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.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
preparing for cloud work110 // with Ctrl or Cmd held, goes on in this window, which has no tabs.
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.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 }
preparing for cloud work117 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;
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.133 if (url.origin !== location.origin || outside(url)) {
preparing for cloud work134 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) {
The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.149 if (url.origin === location.origin && !outside(url)) location.assign(url.href);
preparing for cloud work150 else bridge.openUrl(url.href);
151 return null;
152 }
153 return typeof open === "function" ? open.call(window, href, target, features) : null;
154 };
The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again.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 );
preparing for cloud work414})();