Skip to content
159 linesCodeBlameRaw
1/**
2 * The runner the desktop app keeps on this computer, from the site's side
3 * (apps/desktop/src-tauri/src/runner). The app finds out what to run for,
4 * and gets its registration token, through the page:
5 *
6 * 1. On every page, the page tells the app who is signed in, the
7 * workspaces they own (only owners add runners) and the one they are
8 * in (`offer`). The app answers with the workspace it wants a
9 * registration token for, if any, and asks again later (`onWanted`).
10 * 2. For that workspace, the page asks the server for a token
11 * (`POST /-/desktop/runner`, owners only, same origin) and hands it to
12 * the app (`register`), or says why there is none. It is never shown
13 * or kept here.
14 * 3. Whenever the runner's state changes, and every few minutes while g1t
15 * is open, the page sends the app's diagnosis to the server
16 * (`report`), so the workspace's Runners page lists every person's
17 * desktop with its state, why and the fix, in the app's own words.
18 */
19import { useEffect, useState } from "react";
20
21import type { DesktopBackend, DesktopCapability, DesktopDiagnosis, DesktopReport } from "@g1t/contracts";
22
23/** A fix, as a button: its id is the app's (`runner_control`). */
24export type DesktopRunnerAction = { id: string; label: string };
25
26/** A kind of job the sandbox runs, with its fixes' buttons (`diagnosis::CapabilityLine` in the app). */
27export type DesktopRunnerCapability = DesktopCapability & { actions: DesktopRunnerAction[] };
28
29/** What the app says of its runner (`runner::View` in the app). */
30export type DesktopRunnerView = {
31 diagnosis: Omit<DesktopDiagnosis, "capabilities"> & { actions: DesktopRunnerAction[]; capabilities?: DesktopRunnerCapability[] };
32 closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
33 running: boolean;
34 version: string | null;
35 backend: DesktopBackend;
36 workspace: string | null;
37 workspaces: { slug: string; name: string | null }[];
38 enabled: boolean;
39 paused: boolean;
40 link: string | null;
41 machine: string;
42 platform: string;
43 log: string[];
44};
45
46export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null };
47
48/** `window.g1tDesktop.runner`, from the app's bridge.js. */
49export type DesktopRunnerBridge = {
50 status(): Promise<DesktopRunnerView>;
51 offer(offer: DesktopRunnerOffer): Promise<string | null>;
52 register(answer: { workspace: string; token?: string | null; allowed?: boolean; error?: string | null }): Promise<void>;
53 act(action: string): Promise<void>;
54 onWanted(listener: (workspace: string) => void): () => void;
55 onStatus(listener: (view: DesktopRunnerView) => void): () => void;
56};
57
58/** The bridge's runner, inside the desktop app (lib/notify-client.ts describes the rest of `window.g1tDesktop`). */
59export function desktopRunner(): DesktopRunnerBridge | null {
60 if (typeof window === "undefined") return null;
61 const runner = window.g1tDesktop?.runner;
62 return runner && typeof runner.status === "function" ? runner : null;
63}
64
65/** This computer's runner, live, inside the desktop app; null elsewhere. */
66export function useDesktopRunner(): DesktopRunnerView | null {
67 const [view, setView] = useState<DesktopRunnerView | null>(null);
68 useEffect(() => {
69 const runner = desktopRunner();
70 if (!runner) return;
71 let live = true;
72 runner
73 .status()
74 .then((first) => {
75 if (live) setView(first);
76 })
77 .catch(() => {});
78 const stop = runner.onStatus((next) => setView(next));
79 return () => {
80 live = false;
81 stop();
82 };
83 }, []);
84 return view;
85}
86
87/** How often a desktop says it is still open, with nothing new: within the server's ten minutes. */
88export const HEARTBEAT_MS = 4 * 60 * 1000;
89
90/** What goes to the server, without the buttons, which only work on that computer. */
91export function reportOf(view: DesktopRunnerView): DesktopReport | null {
92 if (!view.workspace) return null;
93 const plain = ({ code, tone, title, cause, fix, capabilities }: DesktopDiagnosis): DesktopDiagnosis => {
94 const said: DesktopDiagnosis = { code, tone, title, cause, fix };
95 if (capabilities?.length) said.capabilities = capabilities.map(({ id, label, tone, title, cause, fix }) => ({ id, label, tone, title, cause: cause ?? null, fix: fix ?? null }));
96 return said;
97 };
98 return { workspace: view.workspace, machine: view.machine, backend: view.backend, version: view.version, diagnosis: plain(view.diagnosis), closed: plain(view.closed) };
99}
100
101/** Whether a report is due: something the Runners page shows changed, or the heartbeat. */
102export function reportDue(last: { key: string; at: number } | null, report: DesktopReport, now: number): boolean {
103 if (!last) return true;
104 return last.key !== reportKey(report) || now - last.at >= HEARTBEAT_MS;
105}
106
107export function reportKey(report: DesktopReport): string {
108 const { diagnosis } = report;
109 const capabilities = (diagnosis.capabilities ?? []).map((c) => [c.id, c.tone, c.title, c.cause, c.fix].join("\u0001"));
110 return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix, ...capabilities].join("\u0000");
111}
112
113/** A string with something in it, or null. */
114export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null);
115
116const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"];
117const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"];
118
119export function backendOf(value: unknown): DesktopBackend | null {
120 return BACKENDS.find((backend) => backend === value) ?? null;
121}
122
123/** A diagnosis as the app sent it, or null when it is not one. */
124export function diagnosisOf(value: unknown): DesktopDiagnosis | null {
125 if (!value || typeof value !== "object") return null;
126 const given = value as Record<string, unknown>;
127 const code = text(given.code);
128 const title = text(given.title);
129 const tone = TONES.find((t) => t === given.tone);
130 if (!code || !title || !tone) return null;
131 const said: DesktopDiagnosis = { code, tone, title, cause: text(given.cause), fix: text(given.fix) };
132 const capabilities = Array.isArray(given.capabilities)
133 ? given.capabilities.map(capabilityOf).filter((c): c is DesktopCapability => c !== null).slice(0, MAX_CAPABILITIES)
134 : [];
135 if (capabilities.length > 0) said.capabilities = capabilities;
136 return said;
137}
138
139/** The most kinds of job a desktop reports (the server keeps as many). */
140const MAX_CAPABILITIES = 4;
141
142/** A kind of job as the app sent it, or null when it is not one. */
143function capabilityOf(value: unknown): DesktopCapability | null {
144 if (!value || typeof value !== "object") return null;
145 const given = value as Record<string, unknown>;
146 const id = text(given.id);
147 const label = text(given.label);
148 const title = text(given.title);
149 const tone = TONES.find((t) => t === given.tone);
150 if (!id || !label || !title || !tone) return null;
151 return { id, label, tone, title, cause: text(given.cause), fix: text(given.fix) };
152}
153
154/** The words for how a desktop runs jobs, as its settings say them. */
155export const BACKEND_LABEL: Record<DesktopBackend, string> = {
156 sandboxed: "Sandboxed",
157 docker: "In Docker (not a sandbox)",
158 host: "Directly on the computer (not a sandbox)",
159};