Skip to content
84 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 ? { workspace, token: answer.token } : { workspace, allowed: answer.allowed !== false, error: answer.error ?? `g1t answered ${response.status}.` },
36 );
37 } catch {
38 // Offline, or the page is leaving: the app asks again in a minute.
39 } finally {
40 asking = null;
41 }
42 };
43 const stop = runner.onWanted((workspace) => void register(workspace));
44 void runner
45 .offer(JSON.parse(key) as DesktopRunnerOffer)
46 .then((wanted) => {
47 if (wanted) void register(wanted);
48 })
49 .catch(() => {});
50 return stop;
51 }, [key]);
52
53 // Reporting: what the Runners page shows of this computer, on each change and as a heartbeat.
54 useEffect(() => {
55 const runner = desktopRunner();
56 if (!runner || !offer.signedIn) return;
57 const send = (report: DesktopReport | null) => {
58 if (!report || !offer.workspaces.some((w) => w.slug === report.workspace)) return;
59 const now = Date.now();
60 if (!reportDue(last.current, report, now)) return;
61 last.current = { key: reportKey(report), at: now };
62 void fetch("/-/desktop/runner", {
63 method: "POST",
64 headers: { "content-type": "application/json" },
65 body: JSON.stringify({ intent: "report", ...report }),
66 }).catch(() => {
67 last.current = null;
68 });
69 };
70 const look = () =>
71 void runner
72 .status()
73 .then((view) => send(reportOf(view)))
74 .catch(() => {});
75 look();
76 const stop = runner.onStatus((view) => send(reportOf(view)));
77 const beat = window.setInterval(look, 60_000);
78 return () => {
79 stop();
80 window.clearInterval(beat);
81 };
82 }, [key]); // eslint-disable-line react-hooks/exhaustive-deps
83 return null;
84}