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.

86 linesCodeBlameRaw
1import { useEffect, useRef } from "react";
2
3import type { DesktopReport } from "@g1t/contracts";
4
5import { type DesktopRunnerOffer, desktopRunner, reportDue, reportKey, reportOf } from "../lib/desktop-runner";
6
7/**
8 * Inside the desktop app, on every page: the link between the app's runner
9 * and the site (lib/desktop-runner.ts). It tells the app who is signed in
10 * and what they own, makes the registration token the app asks for, and
11 * keeps the server told how the runner is. It draws nothing.
12 */
13export function DesktopRunnerLink({ offer }: { offer: DesktopRunnerOffer }) {
14 const key = JSON.stringify(offer);
15 const last = useRef<{ key: string; at: number } | null>(null);
16
17 // Registering: the token goes straight from the server to the app.
18 useEffect(() => {
19 const runner = desktopRunner();
20 if (!runner) return;
21 let asking: string | null = null;
22 const signedIn = (JSON.parse(key) as DesktopRunnerOffer).signedIn;
23 const register = async (workspace: string) => {
24 // Signed out, the app says so itself, from the offer.
25 if (!signedIn || asking === workspace) return;
26 asking = workspace;
27 try {
28 const response = await fetch("/-/desktop/runner", {
29 method: "POST",
30 headers: { "content-type": "application/json" },
31 body: JSON.stringify({ intent: "token", workspace }),
32 });
33 const answer = (await response.json().catch(() => ({}))) as { token?: string; error?: string; allowed?: boolean };
34 await runner.register(
35 answer.token
36 ? { workspace, token: answer.token }
37 : { workspace, allowed: answer.allowed !== false, error: answer.error ?? `g1t answered ${response.status}.` },
38 );
39 } catch {
40 // Offline, or the page is leaving: the app asks again in a minute.
41 } finally {
42 asking = null;
43 }
44 };
45 const stop = runner.onWanted((workspace) => void register(workspace));
46 void runner
47 .offer(JSON.parse(key) as DesktopRunnerOffer)
48 .then((wanted) => {
49 if (wanted) void register(wanted);
50 })
51 .catch(() => {});
52 return stop;
53 }, [key]);
54
55 // Reporting: what the Runners page shows of this computer, on each change and as a heartbeat.
56 useEffect(() => {
57 const runner = desktopRunner();
58 if (!runner || !offer.signedIn) return;
59 const send = (report: DesktopReport | null) => {
60 if (!report || !offer.workspaces.some((w) => w.slug === report.workspace)) return;
61 const now = Date.now();
62 if (!reportDue(last.current, report, now)) return;
63 last.current = { key: reportKey(report), at: now };
64 void fetch("/-/desktop/runner", {
65 method: "POST",
66 headers: { "content-type": "application/json" },
67 body: JSON.stringify({ intent: "report", ...report }),
68 }).catch(() => {
69 last.current = null;
70 });
71 };
72 const look = () =>
73 void runner
74 .status()
75 .then((view) => send(reportOf(view)))
76 .catch(() => {});
77 look();
78 const stop = runner.onStatus((view) => send(reportOf(view)));
79 const beat = window.setInterval(look, 60_000);
80 return () => {
81 stop();
82 window.clearInterval(beat);
83 };
84 }, [key]); // eslint-disable-line react-hooks/exhaustive-deps
85 return null;
86}