Skip to content
131 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, 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/** What the app says of its runner (`runner::View` in the app). */
27export type DesktopRunnerView = {
28 diagnosis: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
29 closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
30 running: boolean;
31 version: string | null;
32 backend: DesktopBackend;
33 workspace: string | null;
34 workspaces: { slug: string; name: string | null }[];
35 enabled: boolean;
36 paused: boolean;
37 link: string | null;
38 machine: string;
39 platform: string;
40 log: string[];
41};
42
43export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null };
44
45/** `window.g1tDesktop.runner`, from the app's bridge.js. */
46export type DesktopRunnerBridge = {
47 status(): Promise<DesktopRunnerView>;
48 offer(offer: DesktopRunnerOffer): Promise<string | null>;
49 register(answer: { workspace: string; token?: string | null; allowed?: boolean; error?: string | null }): Promise<void>;
50 act(action: string): Promise<void>;
51 onWanted(listener: (workspace: string) => void): () => void;
52 onStatus(listener: (view: DesktopRunnerView) => void): () => void;
53};
54
55/** The bridge's runner, inside the desktop app (lib/notify-client.ts describes the rest of `window.g1tDesktop`). */
56export function desktopRunner(): DesktopRunnerBridge | null {
57 if (typeof window === "undefined") return null;
58 const runner = window.g1tDesktop?.runner;
59 return runner && typeof runner.status === "function" ? runner : null;
60}
61
62/** This computer's runner, live, inside the desktop app; null elsewhere. */
63export function useDesktopRunner(): DesktopRunnerView | null {
64 const [view, setView] = useState<DesktopRunnerView | null>(null);
65 useEffect(() => {
66 const runner = desktopRunner();
67 if (!runner) return;
68 let live = true;
69 runner
70 .status()
71 .then((first) => {
72 if (live) setView(first);
73 })
74 .catch(() => {});
75 const stop = runner.onStatus((next) => setView(next));
76 return () => {
77 live = false;
78 stop();
79 };
80 }, []);
81 return view;
82}
83
84/** How often a desktop says it is still open, with nothing new: within the server's ten minutes. */
85export const HEARTBEAT_MS = 4 * 60 * 1000;
86
87/** What goes to the server, without the buttons, which only work on that computer. */
88export function reportOf(view: DesktopRunnerView): DesktopReport | null {
89 if (!view.workspace) return null;
90 const plain = ({ code, tone, title, cause, fix }: DesktopDiagnosis): DesktopDiagnosis => ({ code, tone, title, cause, fix });
91 return { workspace: view.workspace, machine: view.machine, backend: view.backend, version: view.version, diagnosis: plain(view.diagnosis), closed: plain(view.closed) };
92}
93
94/** Whether a report is due: something the Runners page shows changed, or the heartbeat. */
95export function reportDue(last: { key: string; at: number } | null, report: DesktopReport, now: number): boolean {
96 if (!last) return true;
97 return last.key !== reportKey(report) || now - last.at >= HEARTBEAT_MS;
98}
99
100export function reportKey(report: DesktopReport): string {
101 const { diagnosis } = report;
102 return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix].join("\u0000");
103}
104
105/** A string with something in it, or null. */
106export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null);
107
108const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"];
109const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"];
110
111export function backendOf(value: unknown): DesktopBackend | null {
112 return BACKENDS.find((backend) => backend === value) ?? null;
113}
114
115/** A diagnosis as the app sent it, or null when it is not one. */
116export function diagnosisOf(value: unknown): DesktopDiagnosis | null {
117 if (!value || typeof value !== "object") return null;
118 const given = value as Record<string, unknown>;
119 const code = text(given.code);
120 const title = text(given.title);
121 const tone = TONES.find((t) => t === given.tone);
122 if (!code || !title || !tone) return null;
123 return { code, tone, title, cause: text(given.cause), fix: text(given.fix) };
124}
125
126/** The words for how a desktop runs jobs, as its settings say them. */
127export const BACKEND_LABEL: Record<DesktopBackend, string> = {
128 sandboxed: "Sandboxed",
129 docker: "In Docker (not a sandbox)",
130 host: "Directly on the computer (not a sandbox)",
131};