| 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 | */ |
| 19 | import { useEffect, useState } from "react"; |
| 20 | |
| 21 | import type { DesktopBackend, DesktopDiagnosis, DesktopReport } from "@g1t/contracts"; |
| 22 | |
| 23 | /** A fix, as a button: its id is the app's (`runner_control`). */ |
| 24 | export type DesktopRunnerAction = { id: string; label: string }; |
| 25 | |
| 26 | /** What the app says of its runner (`runner::View` in the app). */ |
| 27 | export 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 | |
| 43 | export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null }; |
| 44 | |
| 45 | /** `window.g1tDesktop.runner`, from the app's bridge.js. */ |
| 46 | export 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`). */ |
| 56 | export 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. */ |
| 63 | export 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. */ |
| 85 | export const HEARTBEAT_MS = 4 * 60 * 1000; |
| 86 | |
| 87 | /** What goes to the server, without the buttons, which only work on that computer. */ |
| 88 | export 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. */ |
| 95 | export 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 | |
| 100 | export 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. */ |
| 106 | export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null); |
| 107 | |
| 108 | const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"]; |
| 109 | const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"]; |
| 110 | |
| 111 | export 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. */ |
| 116 | export 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. */ |
| 127 | export 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 | }; |