Skip to content

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

g1t.sh
396 linesCodeBlameRaw
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 // Whether g1t's own title bar is above this page ("custom"), or the
13 // system's ("system"). The bar is a page of its own (src/frame.rs); this
14 // page only tells it what to show (`report`), and is never under it.
15 var FRAME = "__FRAME__";
16 // The site's pages for reading about g1t, not using it, which open in
17 // the browser (navigation.rs); one ending in "/" is every page under it.
18 var OUTSIDE = "__OUTSIDE__";
19
20 function invoke(command, args) {
21 var core = window.__TAURI__ && window.__TAURI__.core;
22 if (core && typeof core.invoke === "function") return core.invoke(command, args || {});
23 var internals = window.__TAURI_INTERNALS__;
24 if (internals && typeof internals.invoke === "function") return internals.invoke(command, args || {});
25 return Promise.reject(new Error("g1t desktop: this page cannot reach the app"));
26 }
27
28 function quietly(promise) {
29 promise.catch(function (error) {
30 console.warn("g1t desktop:", error && error.message ? error.message : error);
31 });
32 }
33
34 var listeners = [];
35 var listening = false;
36 function listen() {
37 if (listening) return;
38 listening = true;
39 var events = window.__TAURI__ && window.__TAURI__.event;
40 if (!events || typeof events.listen !== "function") return;
41 events
42 .listen("g1t-navigate", function (event) {
43 var href = event && event.payload;
44 if (typeof href !== "string") return;
45 listeners.slice().forEach(function (listener) {
46 try {
47 listener(href);
48 } catch (error) {
49 console.warn("g1t desktop: a navigate listener failed", error);
50 }
51 });
52 })
53 .catch(function () {});
54 }
55
56 // An app event, to a listener; returns what stops listening.
57 function on(name, listener) {
58 var events = window.__TAURI__ && window.__TAURI__.event;
59 if (!events || typeof events.listen !== "function") return function () {};
60 var stop = null;
61 var stopped = false;
62 events
63 .listen(name, function (event) {
64 try {
65 listener(event && event.payload);
66 } catch (error) {
67 console.warn("g1t desktop: a " + name + " listener failed", error);
68 }
69 })
70 .then(function (unlisten) {
71 if (stopped) unlisten();
72 else stop = unlisten;
73 })
74 .catch(function () {});
75 return function () {
76 stopped = true;
77 if (stop) stop();
78 };
79 }
80
81 var bridge = {
82 version: VERSION,
83 platform: PLATFORM,
84 // A native notification, for a message to the person while the window is behind another.
85 notify: function (notification) {
86 quietly(invoke("notify", { title: String(notification.title || "g1t"), body: String(notification.body || "") }));
87 },
88 // What needs the person, on the icon and the tray; 0 clears it.
89 setBadge: function (count) {
90 quietly(invoke("set_badge", { count: Math.max(0, Math.floor(Number(count) || 0)) }));
91 },
92 // A link to somewhere else, in the browser the system uses.
93 openUrl: function (url) {
94 quietly(invoke("open_url", { url: String(url) }));
95 },
96 // g1t:// links, as site paths such as "/acme/-/chat/dm/chn_1".
97 onNavigate: function (listener) {
98 listeners.push(listener);
99 listen();
100 return function () {
101 var at = listeners.indexOf(listener);
102 if (at >= 0) listeners.splice(at, 1);
103 };
104 },
105 // Looks for an update now: "current", "ready:<version>", or rejects with why.
106 checkForUpdates: function () {
107 return invoke("check_for_updates");
108 },
109 // The app's own settings: the dialog this page draws (`settings.onOpen`),
110 // or their own window when the page can't.
111 openSettings: function () {
112 quietly(invoke("open_settings"));
113 },
114 // The app's settings, for the dialog the site draws over itself, and
115 // the runner's menu under the title bar's dot (src/settings.rs). The
116 // page says it draws them with `ready`; until then they open in windows
117 // of their own.
118 settings: Object.freeze({
119 ready: function (ready) {
120 return invoke("site_ready", { ready: ready !== false });
121 },
122 get: function () {
123 return invoke("get_settings");
124 },
125 // Every setting but opening at sign-in, as `get` has them (camelCase).
126 save: function (settings) {
127 return invoke("save_settings", { settings: settings });
128 },
129 setOpenAtLogin: function (on) {
130 return invoke("set_open_at_login", { on: !!on });
131 },
132 // "current", "ready:<version>", or rejects with why; the dialog says it, so no notification.
133 checkForUpdates: function () {
134 return invoke("check_for_updates", { shown: true });
135 },
136 restart: function () {
137 return invoke("restart");
138 },
139 restartToUpdate: function () {
140 return invoke("restart_to_update");
141 },
142 changeServer: function () {
143 return invoke("change_server");
144 },
145 // Open the dialog: { section: "runner" | "updates" | null, check: boolean }.
146 onOpen: function (listener) {
147 return on("g1t-open-settings", listener);
148 },
149 // The runner menu: { action: "toggle" | "open", at: share of the bar's width | null }.
150 onRunnerMenu: function (listener) {
151 return on("g1t-runner-menu", listener);
152 },
153 // An update finished downloading, with its version.
154 onUpdateReady: function (listener) {
155 return on("g1t-update-ready", listener);
156 },
157 // Whether the runner menu is open, which the title bar's dot shows.
158 setRunnerMenuOpen: function (open) {
159 runnerMenu = !!open;
160 report();
161 },
162 }),
163 // The web inspector for this page, while Developer tools is on in the
164 // app's settings (src/devtools.rs); rejects with where to turn it on
165 // while it is off.
166 devtools: Object.freeze({
167 open: function () {
168 return invoke("open_devtools");
169 },
170 }),
171 // The title bar's search, in its middle: the site hands it a label
172 // ("Search Flagon") and what opens its search. Returns what takes it away.
173 setSearch: function (label, open) {
174 var mine = typeof open === "function" ? { label: String(label || "Search"), open: open } : null;
175 search = mine;
176 report();
177 return function () {
178 if (search === mine) {
179 search = null;
180 report();
181 }
182 };
183 },
184 // Whether g1t draws the title bar, and how tall it is: 0 under the system's.
185 // The page is below it, never under it: this says only that the bar is there.
186 titleBarHeight: FRAME === "custom" ? 36 : 0,
187 // The runner the app keeps on this computer (src/runner/mod.rs).
188 runner: Object.freeze({
189 // What it is doing: its diagnosis (why it isn't running, and the
190 // fix), the workspace, how jobs run, and its last log lines.
191 status: function () {
192 return invoke("runner_status");
193 },
194 // Who is signed in, the workspaces they own, and the one they are in;
195 // resolves to the workspace a registration token is wanted for, or null.
196 offer: function (offer) {
197 return invoke("runner_offer", {
198 signedIn: !!offer.signedIn,
199 workspaces: (offer.workspaces || []).map(function (w) {
200 return { slug: String(w.slug), name: w.name == null ? null : String(w.name) };
201 }),
202 current: offer.current == null ? null : String(offer.current),
203 });
204 },
205 // A registration token for the workspace the app asked for, or why there is none.
206 register: function (answer) {
207 return invoke("runner_register", {
208 workspace: String(answer.workspace),
209 token: answer.token == null ? null : String(answer.token),
210 allowed: answer.allowed !== false,
211 error: answer.error == null ? null : String(answer.error),
212 });
213 },
214 // Start, stop, or a diagnosis's fix, by its id; Start and Stop of one
215 // kind of job alone (`linux`, `windows`) with its id as `capability`.
216 act: function (action, capability) {
217 var args = { action: String(action) };
218 if (capability != null) args.capability = String(capability);
219 return invoke("runner_control", args);
220 },
221 // The app wants a registration token for a workspace.
222 onWanted: function (listener) {
223 return on("g1t-runner-wanted", listener);
224 },
225 // The runner's status changed.
226 onStatus: function (listener) {
227 return on("g1t-runner-status", listener);
228 },
229 }),
230 };
231 Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false });
232
233 // Links out. A link to another site, or to one of the site's pages for
234 // reading about g1t, opens in the system's browser, and the window stays
235 // where it is; a link that asks for a new tab, or a click
236 // with Ctrl or Cmd held, goes on in this window, which has no tabs.
237 function outside(url) {
238 var path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : url.pathname;
239 return OUTSIDE.some(function (page) {
240 return page.charAt(page.length - 1) === "/" ? path.indexOf(page) === 0 : path === page;
241 });
242 }
243 function destination(href) {
244 try {
245 var url = new URL(href, location.href);
246 return /^https?:$/.test(url.protocol) ? url : null;
247 } catch (error) {
248 return null;
249 }
250 }
251 document.addEventListener(
252 "click",
253 function (event) {
254 if (event.defaultPrevented || event.button !== 0) return;
255 var anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null;
256 if (!anchor || anchor.hasAttribute("download")) return;
257 var url = destination(anchor.getAttribute("href"));
258 if (!url) return;
259 if (url.origin !== location.origin || outside(url)) {
260 event.preventDefault();
261 event.stopImmediatePropagation();
262 bridge.openUrl(url.href);
263 } else if (anchor.target === "_blank" || event.metaKey || event.ctrlKey) {
264 event.preventDefault();
265 event.stopImmediatePropagation();
266 location.assign(url.href);
267 }
268 },
269 true,
270 );
271 var open = window.open;
272 window.open = function (href, target, features) {
273 var url = destination(href);
274 if (url) {
275 if (url.origin === location.origin && !outside(url)) location.assign(url.href);
276 else bridge.openUrl(url.href);
277 return null;
278 }
279 return typeof open === "function" ? open.call(window, href, target, features) : null;
280 };
281
282 // F12 opens the web inspector, or closes it, while Developer tools is on;
283 // the View menu's row has Ctrl+Shift+I (⌥⌘I on macOS). With it off, or
284 // on a page that isn't the site's, the app says no and the key is the
285 // page's as ever.
286 document.addEventListener(
287 "keydown",
288 function (event) {
289 if (event.key !== "F12" || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
290 invoke("open_devtools", { toggle: true }).catch(function () {});
291 },
292 true,
293 );
294
295 // ── The title bar ─────────────────────────────────────────────────────
296 // What the bar above the page shows of it: the title, the search the
297 // site handed over (`setSearch`), whether there is a page to go back or
298 // forward to, the page's colours, so the bar is the colour of g1t's
299 // rail, light or dark, or of whatever page is showing, and whether the
300 // runner menu is open under the bar's dot. Sent whenever any of it
301 // changes; the bar's search comes back as an event.
302 var search = null;
303 var runnerMenu = false;
304 var report = function () {};
305 if (FRAME !== "custom") return;
306
307 // A colour the page names, resolved: `var(--color-rail)` is light-dark(…) until it is drawn.
308 var probe = null;
309 function resolve(value) {
310 if (!document.body) return "";
311 if (!probe) {
312 probe = document.createElement("span");
313 probe.setAttribute("aria-hidden", "true");
314 probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;";
315 }
316 if (probe.parentNode !== document.body) document.body.appendChild(probe);
317 probe.style.color = "";
318 probe.style.color = value;
319 var color = getComputedStyle(probe).color;
320 probe.remove();
321 return color;
322 }
323 function opaque(color) {
324 return !!color && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
325 }
326 function luminance(color) {
327 var parts = (color.match(/[\d.]+/g) || []).map(Number);
328 return parts.length < 3 ? 0 : (0.2126 * parts[0] + 0.7152 * parts[1] + 0.0722 * parts[2]) / 255;
329 }
330 // g1t's pages name their colours (apps/web/app/app.css); any other page has its own background and text.
331 function colors() {
332 if (!document.body) return null;
333 var root = getComputedStyle(document.documentElement);
334 var ours = root.getPropertyValue("--color-rail").trim() !== "";
335 var body = getComputedStyle(document.body);
336 var background = ours ? resolve("var(--color-rail)") : body.backgroundColor;
337 if (!opaque(background)) background = root.backgroundColor;
338 if (!opaque(background)) return null;
339 var foreground = ours ? resolve("var(--color-fg)") : body.color;
340 return {
341 background: background,
342 foreground: foreground,
343 muted: ours ? resolve("var(--color-muted)") : foreground,
344 accent: ours ? resolve("var(--color-accent)") : foreground,
345 dark: luminance(background) < 0.5,
346 };
347 }
348
349 var sent = "";
350 var waiting = 0;
351 report = function () {
352 clearTimeout(waiting);
353 waiting = setTimeout(function () {
354 var nav = window.navigation;
355 var page = {
356 title: document.title || "g1t",
357 search: search ? search.label : null,
358 canGoBack: nav && typeof nav.canGoBack === "boolean" ? nav.canGoBack : null,
359 canGoForward: nav && typeof nav.canGoForward === "boolean" ? nav.canGoForward : null,
360 colors: colors(),
361 runnerMenu: runnerMenu,
362 };
363 var text = JSON.stringify(page);
364 if (text === sent) return;
365 sent = text;
366 invoke("title_bar_page", { page: page }).catch(function () {});
367 }, 30);
368 };
369
370 document.addEventListener("DOMContentLoaded", function () {
371 report();
372 if (document.head) new MutationObserver(report).observe(document.head, { subtree: true, childList: true, characterData: true });
373 // The site's Light, Dark or Auto (data-theme on <html>), and the system's own.
374 new MutationObserver(report).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class", "style"] });
375 });
376 window.addEventListener("load", report);
377 window.addEventListener("pageshow", report);
378 window.addEventListener("popstate", report);
379 if (window.matchMedia) {
380 var scheme = window.matchMedia("(prefers-color-scheme: dark)");
381 if (typeof scheme.addEventListener === "function") scheme.addEventListener("change", report);
382 }
383 if (window.navigation && typeof window.navigation.addEventListener === "function") {
384 window.navigation.addEventListener("currententrychange", report);
385 }
386
387 // The bar's search was clicked: the site's search opens.
388 var events = window.__TAURI__ && window.__TAURI__.event;
389 if (events && typeof events.listen === "function") {
390 events
391 .listen("g1t-search", function () {
392 if (search) search.open();
393 })
394 .catch(function () {});
395 }
396})();