Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.
42 files+3321−330/42 viewed
| 225 | 225 | .foot { | |
| 226 | 226 | text-align: center; | |
| 227 | 227 | } | |
| 228 | + | code { | |
| 229 | + | font: 0.8125rem ui-monospace, SFMono-Regular, Consolas, monospace; | |
| 230 | + | } | |
| 231 | + | /* The runner's state: a dot in its tone, what it is doing, and when it | |
| 232 | + | isn't running, why and the fix, with its buttons. */ | |
| 233 | + | .diagnosis { | |
| 234 | + | display: flex; | |
| 235 | + | align-items: flex-start; | |
| 236 | + | gap: 0.75rem; | |
| 237 | + | margin: 0.25rem 0 0.5rem; | |
| 238 | + | padding: 0.75rem; | |
| 239 | + | border: 1px solid var(--line); | |
| 240 | + | border-radius: 10px; | |
| 241 | + | background: var(--surface); | |
| 242 | + | } | |
| 243 | + | .diagnosis-text { | |
| 244 | + | display: flex; | |
| 245 | + | flex-direction: column; | |
| 246 | + | gap: 0.25rem; | |
| 247 | + | min-width: 0; | |
| 248 | + | flex: 1; | |
| 249 | + | font-size: 0.8125rem; | |
| 250 | + | } | |
| 251 | + | .diagnosis-text strong { | |
| 252 | + | font-size: 0.875rem; | |
| 253 | + | font-weight: 500; | |
| 254 | + | } | |
| 255 | + | .diagnosis-text .buttons { | |
| 256 | + | display: flex; | |
| 257 | + | flex-wrap: wrap; | |
| 258 | + | gap: 0.5rem; | |
| 259 | + | margin-top: 0.25rem; | |
| 260 | + | } | |
| 261 | + | .diagnosis-text .buttons:empty { | |
| 262 | + | display: none; | |
| 263 | + | } | |
| 264 | + | .dot { | |
| 265 | + | flex: none; | |
| 266 | + | width: 8px; | |
| 267 | + | height: 8px; | |
| 268 | + | margin-top: 0.4rem; | |
| 269 | + | border-radius: 50%; | |
| 270 | + | background: var(--muted); | |
| 271 | + | } | |
| 272 | + | .dot.ok { | |
| 273 | + | background: #4ade80; | |
| 274 | + | } | |
| 275 | + | .dot.working { | |
| 276 | + | background: #60a5fa; | |
| 277 | + | } | |
| 278 | + | .dot.warning { | |
| 279 | + | background: #fbbf24; | |
| 280 | + | } | |
| 281 | + | .dot.problem { | |
| 282 | + | background: var(--error); | |
| 283 | + | } | |
| 284 | + | .warning-note { | |
| 285 | + | color: #fbbf24; | |
| 286 | + | } | |
| 287 | + | details.log { | |
| 288 | + | padding: 0.75rem 0; | |
| 289 | + | } | |
| 290 | + | details.log summary { | |
| 291 | + | color: var(--muted); | |
| 292 | + | font-size: 0.8125rem; | |
| 293 | + | } | |
| 294 | + | details.log pre { | |
| 295 | + | max-height: 12rem; | |
| 296 | + | overflow: auto; | |
| 297 | + | margin: 0.5rem 0; | |
| 298 | + | padding: 0.5rem; | |
| 299 | + | border-radius: 8px; | |
| 300 | + | background: #050507; | |
| 301 | + | color: var(--muted); | |
| 302 | + | font: 0.75rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; | |
| 303 | + | white-space: pre-wrap; | |
| 304 | + | overflow-wrap: anywhere; | |
| 305 | + | user-select: text; | |
| 306 | + | } |
| 44 | 44 | </label> | |
| 45 | 45 | </section> | |
| 46 | 46 | ||
| 47 | + | <section aria-labelledby="runner-heading" id="runner-section"> | |
| 48 | + | <h2 id="runner-heading">Runner</h2> | |
| 49 | + | <label class="setting"> | |
| 50 | + | <span | |
| 51 | + | ><strong>Run jobs on this computer while g1t is open</strong | |
| 52 | + | ><span class="muted">Your workspace's <code>runs-on: self-hosted</code> jobs run here, at no cost, while g1t is open.</span></span | |
| 53 | + | > | |
| 54 | + | <input type="checkbox" role="switch" id="runner" /> | |
| 55 | + | </label> | |
| 56 | + | <!-- What it is doing; when it isn't running, why and how to fix it (src/runner/diagnosis.rs). --> | |
| 57 | + | <div class="diagnosis" id="runner-diagnosis" aria-live="polite"> | |
| 58 | + | <span class="dot" id="runner-dot" aria-hidden="true"></span> | |
| 59 | + | <span class="diagnosis-text"> | |
| 60 | + | <strong id="runner-title">Runner</strong> | |
| 61 | + | <span class="muted" id="runner-cause"></span> | |
| 62 | + | <span id="runner-fix"></span> | |
| 63 | + | <span class="buttons" id="runner-actions"></span> | |
| 64 | + | </span> | |
| 65 | + | <button type="button" id="runner-toggle">Stop</button> | |
| 66 | + | </div> | |
| 67 | + | <label class="setting"> | |
| 68 | + | <span><strong>How jobs run</strong><span class="muted" id="runner-backend-note"> </span></span> | |
| 69 | + | <select id="runnerBackend"> | |
| 70 | + | <option value="sandboxed">Sandboxed (recommended)</option> | |
| 71 | + | <option value="docker">In Docker</option> | |
| 72 | + | <option value="host">Directly on this computer</option> | |
| 73 | + | </select> | |
| 74 | + | </label> | |
| 75 | + | <label class="setting"> | |
| 76 | + | <span><strong>Workspace</strong><span class="muted" id="runner-workspace-note">The workspace whose jobs it runs. Only a workspace's owners can add runners to it.</span></span> | |
| 77 | + | <select id="runnerWorkspace"></select> | |
| 78 | + | </label> | |
| 79 | + | <div class="setting"> | |
| 80 | + | <span><strong id="runner-machine">This computer</strong><span class="muted" id="runner-version"> </span></span> | |
| 81 | + | <span class="buttons"><button type="button" id="runner-view">View on g1t</button></span> | |
| 82 | + | </div> | |
| 83 | + | <details class="log"> | |
| 84 | + | <summary>Recent log</summary> | |
| 85 | + | <pre id="runner-log">Nothing yet.</pre> | |
| 86 | + | <button type="button" id="runner-open-log">Open the whole log</button> | |
| 87 | + | </details> | |
| 88 | + | </section> | |
| 89 | + | ||
| 47 | 90 | <section aria-labelledby="notifications-heading"> | |
| 48 | 91 | <h2 id="notifications-heading">Notifications</h2> | |
| 49 | 92 | <label class="setting"> |
| 2 | 2 | // the moment it is made. Everything it asks is a command in | |
| 3 | 3 | // src/bridge.rs, answered only to this window. | |
| 4 | 4 | (function () { | |
| 5 | − | var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"]; | |
| 5 | + | var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate", "runner"]; | |
| 6 | + | // What each way of running jobs can reach, said plainly (src/runner/backend.rs). | |
| 7 | + | var BACKENDS = { | |
| 8 | + | sandboxed: { text: "The runner and every job run in a sandbox of their own. Nothing a job does reaches this computer.", warn: false }, | |
| 9 | + | docker: { | |
| 10 | + | text: "Each job runs in a container of its own, but the runner controls this computer's Docker, which can reach your files. Not a sandbox.", | |
| 11 | + | warn: true, | |
| 12 | + | }, | |
| 13 | + | host: { | |
| 14 | + | text: "Jobs run directly on this computer, as you: they can read, change and delete anything your account can. For jobs that need this system itself, such as building a Windows installer.", | |
| 15 | + | warn: true, | |
| 16 | + | }, | |
| 17 | + | }; | |
| 6 | 18 | var current = null; | |
| 7 | 19 | ||
| 8 | 20 | function byId(id) { | |
| ⋯ | |||
| 57 | 69 | byId("version").textContent = "g1t " + view.version; | |
| 58 | 70 | if (!checking) updateStatus(view); | |
| 59 | 71 | byId("server").textContent = hostOf(view.server); | |
| 72 | + | renderRunner(view.runnerView); | |
| 60 | 73 | } | |
| 61 | 74 | ||
| 62 | 75 | function settingsOf() { | |
| ⋯ | |||
| 65 | 78 | settings[key] = byId(key).checked; | |
| 66 | 79 | }); | |
| 67 | 80 | settings.zoom = Number(byId("zoom").value) || 1; | |
| 81 | + | settings.runnerBackend = byId("runnerBackend").value || "sandboxed"; | |
| 82 | + | settings.runnerWorkspace = byId("runnerWorkspace").value || null; | |
| 68 | 83 | return settings; | |
| 69 | 84 | } | |
| 70 | 85 | ||
| 86 | + | // ── The runner ────────────────────────────────────────────────────── | |
| 87 | + | // Its diagnosis, from src/runner/diagnosis.rs, as the tray and the | |
| 88 | + | // site's Runners page show it; its buttons are the fixes. | |
| 89 | + | function option(value, text) { | |
| 90 | + | var item = document.createElement("option"); | |
| 91 | + | item.value = value; | |
| 92 | + | item.textContent = text; | |
| 93 | + | return item; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | function renderRunner(runner) { | |
| 97 | + | if (!runner) return; | |
| 98 | + | var diagnosis = runner.diagnosis; | |
| 99 | + | byId("runner-dot").className = "dot " + diagnosis.tone; | |
| 100 | + | byId("runner-title").textContent = diagnosis.title; | |
| 101 | + | byId("runner-cause").textContent = diagnosis.cause || ""; | |
| 102 | + | byId("runner-fix").textContent = diagnosis.fix || ""; | |
| 103 | + | var actions = byId("runner-actions"); | |
| 104 | + | actions.textContent = ""; | |
| 105 | + | diagnosis.actions.forEach(function (action, at) { | |
| 106 | + | var button = document.createElement("button"); | |
| 107 | + | button.type = "button"; | |
| 108 | + | button.textContent = action.label; | |
| 109 | + | if (at === 0) button.className = "primary"; | |
| 110 | + | button.addEventListener("click", function () { | |
| 111 | + | // Choosing is done here, in this window. | |
| 112 | + | if (action.id === "choose_workspace") return byId("runnerWorkspace").focus(); | |
| 113 | + | if (action.id === "choose_backend") return byId("runnerBackend").focus(); | |
| 114 | + | invoke("runner_control", { action: action.id }).catch(fail); | |
| 115 | + | }); | |
| 116 | + | actions.appendChild(button); | |
| 117 | + | }); | |
| 118 | + | byId("runner").checked = !!runner.enabled; | |
| 119 | + | var on = runner.enabled && !runner.paused; | |
| 120 | + | byId("runner-toggle").textContent = on ? "Stop" : "Start"; | |
| 121 | + | byId("runner-toggle").dataset.action = on ? "stop" : "start"; | |
| 122 | + | ||
| 123 | + | var backend = byId("runnerBackend"); | |
| 124 | + | backend.value = runner.backend; | |
| 125 | + | var about = BACKENDS[runner.backend] || BACKENDS.sandboxed; | |
| 126 | + | byId("runner-backend-note").textContent = about.text; | |
| 127 | + | byId("runner-backend-note").className = about.warn ? "warning-note" : "muted"; | |
| 128 | + | ||
| 129 | + | var workspaces = byId("runnerWorkspace"); | |
| 130 | + | workspaces.textContent = ""; | |
| 131 | + | var known = runner.workspaces.slice(); | |
| 132 | + | var listed = known.some(function (w) { | |
| 133 | + | return w.slug === runner.workspace; | |
| 134 | + | }); | |
| 135 | + | if (runner.workspace && !listed) known.unshift({ slug: runner.workspace, name: null }); | |
| 136 | + | known.forEach(function (w) { | |
| 137 | + | workspaces.appendChild(option(w.slug, w.name ? w.name + " (" + w.slug + ")" : w.slug)); | |
| 138 | + | }); | |
| 139 | + | if (known.length === 0) workspaces.appendChild(option("", "Open g1t, signed in, to choose")); | |
| 140 | + | workspaces.value = runner.workspace || ""; | |
| 141 | + | workspaces.disabled = known.length === 0; | |
| 142 | + | ||
| 143 | + | byId("runner-machine").textContent = "This computer: " + runner.machine; | |
| 144 | + | byId("runner-version").textContent = runner.version ? "g1t-runner " + runner.version : " "; | |
| 145 | + | byId("runner-view").hidden = !runner.link; | |
| 146 | + | byId("runner-log").textContent = runner.log.length ? runner.log.join("\n") : "Nothing yet."; | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | byId("runnerBackend").addEventListener("change", save); | |
| 150 | + | byId("runnerWorkspace").addEventListener("change", save); | |
| 151 | + | byId("runner-toggle").addEventListener("click", function (event) { | |
| 152 | + | invoke("runner_control", { action: event.target.dataset.action || "start" }).catch(fail); | |
| 153 | + | }); | |
| 154 | + | byId("runner-view").addEventListener("click", function () { | |
| 155 | + | invoke("runner_control", { action: "view_runners" }).catch(fail); | |
| 156 | + | }); | |
| 157 | + | byId("runner-open-log").addEventListener("click", function () { | |
| 158 | + | invoke("runner_control", { action: "view_log" }).catch(fail); | |
| 159 | + | }); | |
| 160 | + | ||
| 71 | 161 | function save() { | |
| 72 | 162 | invoke("save_settings", { settings: settingsOf() }).then(render, function (error) { | |
| 73 | 163 | fail(error); | |
| ⋯ | |||
| 152 | 242 | if (events && typeof events.listen === "function") { | |
| 153 | 243 | events.listen("g1t-check-updates", check).catch(function () {}); | |
| 154 | 244 | events | |
| 245 | + | .listen("g1t-runner-status", function (event) { | |
| 246 | + | if (current) current.runnerView = event.payload; | |
| 247 | + | renderRunner(event.payload); | |
| 248 | + | }) | |
| 249 | + | .catch(function () {}); | |
| 250 | + | events | |
| 155 | 251 | .listen("g1t-update-ready", function (event) { | |
| 156 | 252 | if (!checking && typeof event.payload === "string") ready(event.payload); | |
| 157 | 253 | }) | |
| 1253 | 1253 | "reqwest 0.12.28", | |
| 1254 | 1254 | "serde", | |
| 1255 | 1255 | "serde_json", | |
| 1256 | + | "sha2", | |
| 1256 | 1257 | "tauri", | |
| 1257 | 1258 | "tauri-build", | |
| 1258 | 1259 | "tauri-plugin-autostart", |
| 39 | 39 | # Whether the server answers, before the window goes there (the same client the updater brings). | |
| 40 | 40 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } | |
| 41 | 41 | tokio = { version = "1", features = ["time"] } | |
| 42 | + | # Checking a downloaded g1t-runner against its release's SHA-256 (src/runner/native.rs). | |
| 43 | + | sha2 = "0.10" | |
| 42 | 44 | ||
| 43 | 45 | # Keeping the menu bar hidden under g1t's own title bar (src/frame.rs); the | |
| 44 | 46 | # same GTK that Tauri brings. |
| 24 | 24 | "title_bar_page", | |
| 25 | 25 | "title_bar_current", | |
| 26 | 26 | "title_bar_act", | |
| 27 | + | "runner_status", | |
| 28 | + | "runner_offer", | |
| 29 | + | "runner_register", | |
| 30 | + | "runner_control", | |
| 27 | 31 | ]))) | |
| 28 | 32 | .expect("the app's build could not be prepared"); | |
| 29 | 33 | } |
| 13 | 13 | "allow-restart-to-update", | |
| 14 | 14 | "allow-restart", | |
| 15 | 15 | "allow-open-url", | |
| 16 | − | "allow-window-control" | |
| 16 | + | "allow-window-control", | |
| 17 | + | "allow-runner-status", | |
| 18 | + | "allow-runner-control" | |
| 17 | 19 | ] | |
| 18 | 20 | } |
| 53 | 53 | .catch(function () {}); | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | + | // An app event, to a listener; returns what stops listening. | |
| 57 | + | function on(name, listener) { | |
| 58 | + | var events = window.__TAURI__ && window.__TAURI__.event; | |
| 59 | + | if (!events || typeof events.listen !== "function") return function () {}; | |
| 60 | + | var stop = null; | |
| 61 | + | var stopped = false; | |
| 62 | + | events | |
| 63 | + | .listen(name, function (event) { | |
| 64 | + | try { | |
| 65 | + | listener(event && event.payload); | |
| 66 | + | } catch (error) { | |
| 67 | + | console.warn("g1t desktop: a " + name + " listener failed", error); | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | .then(function (unlisten) { | |
| 71 | + | if (stopped) unlisten(); | |
| 72 | + | else stop = unlisten; | |
| 73 | + | }) | |
| 74 | + | .catch(function () {}); | |
| 75 | + | return function () { | |
| 76 | + | stopped = true; | |
| 77 | + | if (stop) stop(); | |
| 78 | + | }; | |
| 79 | + | } | |
| 80 | + | ||
| 56 | 81 | var bridge = { | |
| 57 | 82 | version: VERSION, | |
| 58 | 83 | platform: PLATFORM, | |
| ⋯ | |||
| 101 | 126 | // Whether g1t draws the title bar, and how tall it is: 0 under the system's. | |
| 102 | 127 | // The page is below it, never under it: this says only that the bar is there. | |
| 103 | 128 | titleBarHeight: FRAME === "custom" ? 36 : 0, | |
| 129 | + | // The runner the app keeps on this computer (src/runner/mod.rs). | |
| 130 | + | runner: Object.freeze({ | |
| 131 | + | // What it is doing: its diagnosis (why it isn't running, and the | |
| 132 | + | // fix), the workspace, how jobs run, and its last log lines. | |
| 133 | + | status: function () { | |
| 134 | + | return invoke("runner_status"); | |
| 135 | + | }, | |
| 136 | + | // Who is signed in, the workspaces they own, and the one they are in; | |
| 137 | + | // resolves to the workspace a registration token is wanted for, or null. | |
| 138 | + | offer: function (offer) { | |
| 139 | + | return invoke("runner_offer", { | |
| 140 | + | signedIn: !!offer.signedIn, | |
| 141 | + | workspaces: (offer.workspaces || []).map(function (w) { | |
| 142 | + | return { slug: String(w.slug), name: w.name == null ? null : String(w.name) }; | |
| 143 | + | }), | |
| 144 | + | current: offer.current == null ? null : String(offer.current), | |
| 145 | + | }); | |
| 146 | + | }, | |
| 147 | + | // A registration token for the workspace the app asked for, or why there is none. | |
| 148 | + | register: function (answer) { | |
| 149 | + | return invoke("runner_register", { | |
| 150 | + | workspace: String(answer.workspace), | |
| 151 | + | token: answer.token == null ? null : String(answer.token), | |
| 152 | + | allowed: answer.allowed !== false, | |
| 153 | + | error: answer.error == null ? null : String(answer.error), | |
| 154 | + | }); | |
| 155 | + | }, | |
| 156 | + | // Start, stop, or a diagnosis's fix, by its id. | |
| 157 | + | act: function (action) { | |
| 158 | + | return invoke("runner_control", { action: String(action) }); | |
| 159 | + | }, | |
| 160 | + | // The app wants a registration token for a workspace. | |
| 161 | + | onWanted: function (listener) { | |
| 162 | + | return on("g1t-runner-wanted", listener); | |
| 163 | + | }, | |
| 164 | + | // The runner's status changed. | |
| 165 | + | onStatus: function (listener) { | |
| 166 | + | return on("g1t-runner-status", listener); | |
| 167 | + | }, | |
| 168 | + | }), | |
| 104 | 169 | }; | |
| 105 | 170 | Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false }); | |
| 106 | 171 | ||
| 13 | 13 | use url::Url; | |
| 14 | 14 | ||
| 15 | 15 | use crate::settings::{self, Settings}; | |
| 16 | − | use crate::{badge, config, frame, updates}; | |
| 16 | + | use crate::{badge, config, frame, runner, updates}; | |
| 17 | 17 | ||
| 18 | 18 | const SCRIPT: &str = include_str!("bridge.js"); | |
| 19 | 19 | ||
| ⋯ | |||
| 206 | 206 | if updates::ready(&app).is_some() { | |
| 207 | 207 | updates::restart(&app); | |
| 208 | 208 | } | |
| 209 | + | runner::shutdown(&app); | |
| 209 | 210 | app.restart() | |
| 210 | 211 | } | |
| 211 | 212 | ||
| ⋯ | |||
| 259 | 260 | Ok(()) | |
| 260 | 261 | } | |
| 261 | 262 | ||
| 263 | + | /// The runner's state, diagnosis and recent log: for the site's card for | |
| 264 | + | /// this computer and the settings window. | |
| 265 | + | #[tauri::command] | |
| 266 | + | pub fn runner_status(app: AppHandle, webview: Webview) -> Result<runner::View, String> { | |
| 267 | + | from_site(&app, &webview).or_else(|_| from_settings(&webview))?; | |
| 268 | + | Ok(runner::view(&app)) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | /// The site, on every page: whether someone is signed in, the workspaces | |
| 272 | + | /// they own (the only ones they can add runners to), and the one they are | |
| 273 | + | /// in. Answers the workspace a registration token is wanted for, if one is. | |
| 274 | + | #[tauri::command] | |
| 275 | + | pub fn runner_offer(app: AppHandle, webview: Webview, signed_in: bool, workspaces: Vec<runner::Offered>, current: Option<String>) -> Result<Option<String>, String> { | |
| 276 | + | from_site(&app, &webview)?; | |
| 277 | + | runner::offer(&app, signed_in, workspaces, current); | |
| 278 | + | Ok(runner::wanted(&app)) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /// The site made a registration token for the workspace the app asked | |
| 282 | + | /// for, or says why not: `allowed` is false when the person may not add | |
| 283 | + | /// runners there. The token goes to the runner and nowhere else. | |
| 284 | + | #[tauri::command] | |
| 285 | + | pub fn runner_register(app: AppHandle, webview: Webview, workspace: String, token: Option<String>, allowed: bool, error: Option<String>) -> Result<(), String> { | |
| 286 | + | from_site(&app, &webview)?; | |
| 287 | + | runner::register(&app, workspace, token, allowed, error); | |
| 288 | + | Ok(()) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// A button for the runner: Start, Stop, or a diagnosis's fix | |
| 292 | + | /// (runner/diagnosis.rs), from settings or the site's card. | |
| 293 | + | #[tauri::command] | |
| 294 | + | pub fn runner_control(app: AppHandle, webview: Webview, action: String) -> Result<(), String> { | |
| 295 | + | from_site(&app, &webview).or_else(|_| from_settings(&webview))?; | |
| 296 | + | runner::act(&app, &action) | |
| 297 | + | } | |
| 298 | + | ||
| 262 | 299 | #[cfg(test)] | |
| 263 | 300 | mod tests { | |
| 264 | 301 | #[test] | |
| 166 | 166 | "allow-check-for-updates", | |
| 167 | 167 | "allow-open-settings", | |
| 168 | 168 | "allow-title-bar-page", | |
| 169 | + | "allow-runner-status", | |
| 170 | + | "allow-runner-offer", | |
| 171 | + | "allow-runner-register", | |
| 172 | + | "allow-runner-control", | |
| 169 | 173 | ]; | |
| 170 | 174 | ||
| 171 | 175 | /// What GitHub's pages may call while someone signs in: telling the title bar their title. |
| 3 | 3 | //! the moment it is deployed and nothing here needs a new build for it; | |
| 4 | 4 | //! only what is native lives in this program: notifications from the | |
| 5 | 5 | //! system, a badge on the icon, a tray, a menu, `g1t://` links, the server | |
| 6 | − | //! to use, the app's own settings, and updates that install themselves | |
| 6 | + | //! to use, the app's own settings, updates that install themselves, and a | |
| 7 | + | //! self-hosted runner for the signed-in person (runner/mod.rs) | |
| 7 | 8 | //! (docs.g1t.sh/guides/desktop/). | |
| 8 | 9 | //! | |
| 9 | 10 | //! The site finds the app at `window.g1tDesktop` (bridge.js), the one | |
| ⋯ | |||
| 19 | 20 | mod links; | |
| 20 | 21 | mod menu; | |
| 21 | 22 | mod navigation; | |
| 23 | + | mod runner; | |
| 22 | 24 | mod settings; | |
| 23 | 25 | mod tray; | |
| 24 | 26 | mod updates; | |
| ⋯ | |||
| 60 | 62 | .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![settings::AT_LOGIN]))) | |
| 61 | 63 | .manage(config::State::default()) | |
| 62 | 64 | .manage(frame::State::default()) | |
| 65 | + | .manage(runner::Shared::default()) | |
| 63 | 66 | .invoke_handler(tauri::generate_handler![ | |
| 64 | 67 | bridge::desktop_info, | |
| 65 | 68 | bridge::notify, | |
| ⋯ | |||
| 80 | 83 | bridge::title_bar_page, | |
| 81 | 84 | bridge::title_bar_current, | |
| 82 | 85 | bridge::title_bar_act, | |
| 86 | + | bridge::runner_status, | |
| 87 | + | bridge::runner_offer, | |
| 88 | + | bridge::runner_register, | |
| 89 | + | bridge::runner_control, | |
| 83 | 90 | ]) | |
| 84 | 91 | .on_menu_event(|app, event| menu::handle(app, event.id().as_ref())) | |
| 85 | 92 | .setup(|app| { | |
| ⋯ | |||
| 139 | 146 | }); | |
| 140 | 147 | tray::install(&handle)?; | |
| 141 | 148 | links::install(&handle); | |
| 142 | − | updates::schedule(handle); | |
| 149 | + | updates::schedule(handle.clone()); | |
| 150 | + | // The runner starts on a thread of its own, a few seconds after the window. | |
| 151 | + | runner::install(&handle); | |
| 143 | 152 | Ok(()) | |
| 144 | 153 | }) | |
| 145 | 154 | .build(tauri::generate_context!()) | |
| ⋯ | |||
| 150 | 159 | RunEvent::Reopen { .. } => tray::show(app), | |
| 151 | 160 | // No window open is not a reason to quit: the tray is still there. | |
| 152 | 161 | RunEvent::ExitRequested { api, code: None, .. } => api.prevent_exit(), | |
| 162 | + | // However g1t ends, the runner it started ends with it. | |
| 163 | + | RunEvent::Exit => runner::shutdown(app), | |
| 153 | 164 | _ => { | |
| 154 | 165 | let _ = app; | |
| 155 | 166 | } | |
| 139 | 139 | pub fn handle(app: &AppHandle, id: &str) { | |
| 140 | 140 | match id { | |
| 141 | 141 | "open" => tray::show(app), | |
| 142 | − | "settings" => settings::open(app), | |
| 142 | + | "settings" | "runner-status" => settings::open(app), | |
| 143 | + | "runner-toggle" => crate::runner::toggle(app), | |
| 143 | 144 | "server" => tray::launcher(app, "change"), | |
| 144 | 145 | // The settings window asks, and shows the answer where it cannot be missed. | |
| 145 | 146 | "check" => settings::open_and_check(app), |
| 1 | + | //! How the runner runs on this computer, which is the person's choice in | |
| 2 | + | //! settings, and what each choice needs before it can start. | |
| 3 | + | //! | |
| 4 | + | //! | Backend | How | What a job can reach | | |
| 5 | + | //! | --- | --- | --- | | |
| 6 | + | //! | Sandboxed (the default) | The runner in a sandbox of its own: its image under Sysbox on Linux today (container.rs), a VM that `g1t-runner sandbox` makes on Windows and macOS once it is built (launcher.rs). Jobs are containers inside it: `nested-container` isolation. | Its own container. Not this computer. | | |
| 7 | + | //! | Docker | `g1t-runner` on this computer, driving this computer's Docker (native.rs): `container` isolation. | Its own container; but the runner controls this computer's Docker, which on Docker Desktop is a VM with the person's files shared into it. Not a sandbox. | | |
| 8 | + | //! | On this computer | `g1t-runner --no-docker` (native.rs): `host` isolation. | Everything the person's account can. | | |
| 9 | + | //! | |
| 10 | + | //! The two that are not sandboxes are only ever chosen; nothing falls back | |
| 11 | + | //! to them. When the chosen one cannot start, the app says why and runs | |
| 12 | + | //! nothing. | |
| 13 | + | ||
| 14 | + | use std::process::{Command, Stdio}; | |
| 15 | + | ||
| 16 | + | use serde::{Deserialize, Serialize}; | |
| 17 | + | ||
| 18 | + | use super::launcher; | |
| 19 | + | ||
| 20 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 21 | + | #[serde(rename_all = "snake_case")] | |
| 22 | + | pub enum Backend { | |
| 23 | + | #[default] | |
| 24 | + | Sandboxed, | |
| 25 | + | Docker, | |
| 26 | + | Host, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | impl Backend { | |
| 30 | + | /// How it is said in the tray and the log. | |
| 31 | + | pub fn label(self) -> &'static str { | |
| 32 | + | match self { | |
| 33 | + | Backend::Sandboxed => "sandboxed", | |
| 34 | + | Backend::Docker => "in Docker", | |
| 35 | + | Backend::Host => "on this computer", | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// What starts the runner for a backend, here. | |
| 41 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 42 | + | pub enum Engine { | |
| 43 | + | /// The runner's image under Sysbox, through this computer's Docker. | |
| 44 | + | Sysbox, | |
| 45 | + | /// `g1t-runner sandbox`, which makes and owns the sandbox. | |
| 46 | + | Launcher, | |
| 47 | + | /// `g1t-runner` itself, as a child of the app; `docker` is whether its | |
| 48 | + | /// jobs run in this computer's Docker or directly on it. | |
| 49 | + | Native { docker: bool }, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Why a backend cannot start here (diagnosis.rs says it). | |
| 53 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 54 | + | pub enum Missing { | |
| 55 | + | /// Docker is not installed, or not running. | |
| 56 | + | Docker, | |
| 57 | + | /// No sandbox can be made on this computer yet. | |
| 58 | + | Sandbox, | |
| 59 | + | /// Linux, whose sandbox needs Sysbox in its Docker, which has none. | |
| 60 | + | Sysbox, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// A program run without a console window flashing up on Windows. | |
| 64 | + | pub fn quiet(program: impl AsRef<std::ffi::OsStr>) -> Command { | |
| 65 | + | let mut command = Command::new(program); | |
| 66 | + | #[cfg(windows)] | |
| 67 | + | { | |
| 68 | + | use std::os::windows::process::CommandExt; | |
| 69 | + | const CREATE_NO_WINDOW: u32 = 0x0800_0000; | |
| 70 | + | command.creation_flags(CREATE_NO_WINDOW); | |
| 71 | + | } | |
| 72 | + | command.stdin(Stdio::null()); | |
| 73 | + | command | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Whether this computer's Docker answers. | |
| 77 | + | pub fn docker_ready() -> bool { | |
| 78 | + | quiet("docker") | |
| 79 | + | .args(["version", "--format", "{{.Server.Version}}"]) | |
| 80 | + | .stdout(Stdio::null()) | |
| 81 | + | .stderr(Stdio::null()) | |
| 82 | + | .status() | |
| 83 | + | .is_ok_and(|status| status.success()) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Whether this computer's Docker has Sysbox's runtime. | |
| 87 | + | fn sysbox_ready() -> bool { | |
| 88 | + | quiet("docker") | |
| 89 | + | .args(["info", "--format", "{{json .Runtimes}}"]) | |
| 90 | + | .stderr(Stdio::null()) | |
| 91 | + | .output() | |
| 92 | + | .is_ok_and(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).contains("sysbox-runc")) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// What a backend needs, checked now: how to start it, or why it cannot. | |
| 96 | + | pub fn engine(backend: Backend) -> Result<Engine, Missing> { | |
| 97 | + | match backend { | |
| 98 | + | Backend::Sandboxed => { | |
| 99 | + | if launcher::READY { | |
| 100 | + | return Ok(Engine::Launcher); | |
| 101 | + | } | |
| 102 | + | if !cfg!(target_os = "linux") { | |
| 103 | + | return Err(Missing::Sandbox); | |
| 104 | + | } | |
| 105 | + | if !docker_ready() { | |
| 106 | + | return Err(Missing::Docker); | |
| 107 | + | } | |
| 108 | + | if !sysbox_ready() { | |
| 109 | + | return Err(Missing::Sysbox); | |
| 110 | + | } | |
| 111 | + | Ok(Engine::Sysbox) | |
| 112 | + | } | |
| 113 | + | Backend::Docker => { | |
| 114 | + | if docker_ready() { | |
| 115 | + | Ok(Engine::Native { docker: true }) | |
| 116 | + | } else { | |
| 117 | + | Err(Missing::Docker) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | Backend::Host => Ok(Engine::Native { docker: false }), | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// This computer's name, as the runner is called on g1t: what `g1t-runner` | |
| 125 | + | /// would choose itself, given here so a runner in a container, whose own | |
| 126 | + | /// host name is the container's, has the computer's. | |
| 127 | + | pub fn machine_name() -> String { | |
| 128 | + | let given = ["COMPUTERNAME", "HOSTNAME"] | |
| 129 | + | .iter() | |
| 130 | + | .find_map(|name| std::env::var(name).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty())) | |
| 131 | + | .or_else(|| { | |
| 132 | + | quiet("hostname") | |
| 133 | + | .output() | |
| 134 | + | .ok() | |
| 135 | + | .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned()) | |
| 136 | + | .filter(|name| !name.is_empty()) | |
| 137 | + | }) | |
| 138 | + | .unwrap_or_else(|| "desktop".into()); | |
| 139 | + | let name: String = given | |
| 140 | + | .chars() | |
| 141 | + | .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }) | |
| 142 | + | .take(64) | |
| 143 | + | .collect(); | |
| 144 | + | if name.is_empty() { "desktop".into() } else { name } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[cfg(test)] | |
| 148 | + | mod tests { | |
| 149 | + | use super::*; | |
| 150 | + | ||
| 151 | + | #[test] | |
| 152 | + | fn the_sandbox_is_the_default_and_names_read() { | |
| 153 | + | assert_eq!(Backend::default(), Backend::Sandboxed); | |
| 154 | + | assert_eq!(serde_json::to_string(&Backend::Host).unwrap(), r#""host""#); | |
| 155 | + | assert_eq!(serde_json::from_str::<Backend>(r#""docker""#).unwrap(), Backend::Docker); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | #[test] | |
| 159 | + | fn on_this_computer_needs_nothing_more() { | |
| 160 | + | assert_eq!(engine(Backend::Host), Ok(Engine::Native { docker: false })); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[test] | |
| 164 | + | fn a_machine_name_is_one_g1t_takes() { | |
| 165 | + | let name = machine_name(); | |
| 166 | + | assert!(!name.is_empty() && name.len() <= 64); | |
| 167 | + | assert!(name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')), "{name}"); | |
| 168 | + | } | |
| 169 | + | } |
| 1 | + | //! The Sandboxed backend on Linux today: the runner's image under Sysbox, | |
| 2 | + | //! through this computer's Docker, exactly as docs.g1t.sh/guides/ | |
| 3 | + | //! self-hosted-runners/#run-it-as-a-container runs it. Sysbox gives the | |
| 4 | + | //! runner's container a user namespace of its own and a Docker Engine | |
| 5 | + | //! inside it, so each job is a container inside the runner's container and | |
| 6 | + | //! neither reaches this computer (`nested-container` isolation). The | |
| 7 | + | //! machine's Docker socket is never given to it. | |
| 8 | + | //! | |
| 9 | + | //! The registration and credential are kept on a volume of their own, so | |
| 10 | + | //! the container can be replaced, when a newer image is out, without | |
| 11 | + | //! registering again. Keeping it up to date is pulling the image and, | |
| 12 | + | //! while the runner is idle, putting a new container in the old one's place. | |
| 13 | + | ||
| 14 | + | use std::process::{Child, Stdio}; | |
| 15 | + | ||
| 16 | + | use super::backend::quiet; | |
| 17 | + | ||
| 18 | + | /// The runner's image, published with each release. | |
| 19 | + | pub const IMAGE: &str = "g1t.sh/flagon-io/g1t-runner:latest"; | |
| 20 | + | /// The container the app runs, and the volume its registration is kept on. | |
| 21 | + | pub const NAME: &str = "g1t-desktop-runner"; | |
| 22 | + | pub const VOLUME: &str = "g1t-desktop-runner"; | |
| 23 | + | ||
| 24 | + | fn docker_ok(args: &[&str]) -> bool { | |
| 25 | + | quiet("docker").args(args).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|status| status.success()) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | fn docker_text(args: &[&str]) -> Option<String> { | |
| 29 | + | let out = quiet("docker").args(args).stderr(Stdio::null()).output().ok()?; | |
| 30 | + | out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()).filter(|text| !text.is_empty()) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// The arguments of the `docker run` that starts the runner. Each | |
| 34 | + | /// variable's value comes from the environment of the `docker` command, | |
| 35 | + | /// never its command line, so a registration token is not in the | |
| 36 | + | /// computer's list of processes. | |
| 37 | + | pub fn run_args(names: &[&str]) -> Vec<String> { | |
| 38 | + | let mut args: Vec<String> = ["run", "-d", "--name", NAME, "--runtime=sysbox-runc", "--label", "sh.g1t.desktop=runner", "-v"] | |
| 39 | + | .iter() | |
| 40 | + | .map(|s| s.to_string()) | |
| 41 | + | .collect(); | |
| 42 | + | args.push(format!("{VOLUME}:/data")); | |
| 43 | + | for name in names { | |
| 44 | + | args.push("-e".into()); | |
| 45 | + | args.push((*name).to_owned()); | |
| 46 | + | } | |
| 47 | + | args.push(IMAGE.into()); | |
| 48 | + | args.push("register-and-run".into()); | |
| 49 | + | args | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Fetches the newest image; whether the one here changed. | |
| 53 | + | pub fn pull() -> bool { | |
| 54 | + | let before = docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]); | |
| 55 | + | let pulled = docker_ok(&["pull", "--quiet", IMAGE]); | |
| 56 | + | pulled && docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]) != before | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Whether the image is here at all. | |
| 60 | + | pub fn have_image() -> bool { | |
| 61 | + | docker_ok(&["image", "inspect", IMAGE]) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Whether the running container is from an older image than the one here. | |
| 65 | + | pub fn outdated() -> bool { | |
| 66 | + | let image = docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]); | |
| 67 | + | let running = docker_text(&["inspect", "--format", "{{.Image}}", NAME]); | |
| 68 | + | matches!((image, running), (Some(image), Some(running)) if image != running) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Whether a registration is kept here. | |
| 72 | + | pub fn registered() -> bool { | |
| 73 | + | docker_ok(&["volume", "inspect", VOLUME]) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Starts the runner's container in place of any earlier one, then | |
| 77 | + | /// follows its log: the child's output is the runner's. | |
| 78 | + | pub fn start(envs: &[(&str, String)]) -> Result<Child, String> { | |
| 79 | + | let _ = docker_ok(&["rm", "-f", NAME]); | |
| 80 | + | let names: Vec<&str> = envs.iter().map(|(name, _)| *name).collect(); | |
| 81 | + | let out = quiet("docker") | |
| 82 | + | .args(run_args(&names)) | |
| 83 | + | .envs(envs.iter().map(|(name, value)| (*name, value.as_str()))) | |
| 84 | + | .stdout(Stdio::null()) | |
| 85 | + | .output() | |
| 86 | + | .map_err(|e| format!("could not run docker: {e}"))?; | |
| 87 | + | if !out.status.success() { | |
| 88 | + | return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned()); | |
| 89 | + | } | |
| 90 | + | quiet("docker") | |
| 91 | + | .args(["logs", "--follow", NAME]) | |
| 92 | + | .stdout(Stdio::piped()) | |
| 93 | + | .stderr(Stdio::piped()) | |
| 94 | + | .spawn() | |
| 95 | + | .map_err(|e| format!("could not follow the runner's log: {e}")) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// How the container's runner ended, while the container is still there. | |
| 99 | + | pub fn exit_code() -> Option<i32> { | |
| 100 | + | docker_text(&["inspect", "--format", "{{.State.ExitCode}}", NAME]).and_then(|code| code.parse().ok()) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Stops the container and removes it; its volume stays. | |
| 104 | + | pub fn stop(logs: Option<&mut Child>) { | |
| 105 | + | let _ = docker_ok(&["stop", "--time", "20", NAME]); | |
| 106 | + | let _ = docker_ok(&["rm", "-f", NAME]); | |
| 107 | + | if let Some(logs) = logs { | |
| 108 | + | let _ = logs.kill(); | |
| 109 | + | let _ = logs.wait(); | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Unregisters the runner kept on the volume, then removes the volume. | |
| 114 | + | pub fn forget() { | |
| 115 | + | if registered() { | |
| 116 | + | let mut args = vec!["run", "--rm", "--runtime=sysbox-runc", "-v"]; | |
| 117 | + | let mount = format!("{VOLUME}:/data"); | |
| 118 | + | args.push(&mount); | |
| 119 | + | args.extend([IMAGE, "remove"]); | |
| 120 | + | let _ = docker_ok(&args); | |
| 121 | + | let _ = docker_ok(&["volume", "rm", "-f", VOLUME]); | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[cfg(test)] | |
| 126 | + | mod tests { | |
| 127 | + | use super::*; | |
| 128 | + | ||
| 129 | + | #[test] | |
| 130 | + | fn the_runner_runs_under_sysbox_without_the_machines_docker() { | |
| 131 | + | let args = run_args(&["G1T_RUNNER_URL", "G1T_RUNNER_TOKEN"]); | |
| 132 | + | assert!(args.contains(&"--runtime=sysbox-runc".to_owned())); | |
| 133 | + | assert!(args.contains(&"g1t-desktop-runner:/data".to_owned())); | |
| 134 | + | assert!(!args.iter().any(|a| a.contains("docker.sock") || a == "--privileged"), "{args:?}"); | |
| 135 | + | // Values never on the command line: only the names. | |
| 136 | + | assert!(args.windows(2).any(|w| w[0] == "-e" && w[1] == "G1T_RUNNER_TOKEN")); | |
| 137 | + | assert_eq!(&args[args.len() - 2..], [IMAGE, "register-and-run"]); | |
| 138 | + | } | |
| 139 | + | } |
| 1 | + | //! What the runner is doing, and when it is not running or is failing, why | |
| 2 | + | //! and how to fix it: the one catalogue of those words. The tray shows a | |
| 3 | + | //! diagnosis's title, the settings window all of it with its buttons, and | |
| 4 | + | //! the site shows it as the app sends it, word for word, on its Runners | |
| 5 | + | //! page (and keeps it, so a person's other computers are listed with | |
| 6 | + | //! theirs). The site adds nothing of its own: what it says of a computer | |
| 7 | + | //! whose g1t is closed is `closed` here, sent with every report. | |
| 8 | + | //! | |
| 9 | + | //! Each action is an id the settings window and the site turn into a | |
| 10 | + | //! button; bridge.rs and settings.js do what it says. | |
| 11 | + | ||
| 12 | + | use serde::Serialize; | |
| 13 | + | ||
| 14 | + | /// Every state the runner can be in. | |
| 15 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] | |
| 16 | + | #[serde(rename_all = "snake_case")] | |
| 17 | + | pub enum Code { | |
| 18 | + | /// Running jobs is turned off in settings. | |
| 19 | + | #[default] | |
| 20 | + | Off, | |
| 21 | + | /// The person pressed Stop. | |
| 22 | + | Stopped, | |
| 23 | + | Starting, | |
| 24 | + | /// Waiting for the site to make a registration token. | |
| 25 | + | Registering, | |
| 26 | + | /// Nobody is signed in to g1t in the app. | |
| 27 | + | SignedOut, | |
| 28 | + | /// Signed in, owning no workspace: nowhere to register. | |
| 29 | + | NoWorkspace, | |
| 30 | + | /// The person may not add runners to the workspace. | |
| 31 | + | NotAllowed, | |
| 32 | + | RegisterFailed, | |
| 33 | + | Idle, | |
| 34 | + | Busy, | |
| 35 | + | NoDocker, | |
| 36 | + | NoSandbox, | |
| 37 | + | NoSysbox, | |
| 38 | + | Updating, | |
| 39 | + | UpdateFailed, | |
| 40 | + | Offline, | |
| 41 | + | Crashed, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// How a state should look: a dot's colour. | |
| 45 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] | |
| 46 | + | #[serde(rename_all = "snake_case")] | |
| 47 | + | pub enum Tone { | |
| 48 | + | Ok, | |
| 49 | + | Working, | |
| 50 | + | Off, | |
| 51 | + | Warning, | |
| 52 | + | Problem, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// A one-click fix. | |
| 56 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] | |
| 57 | + | pub struct Action { | |
| 58 | + | pub id: &'static str, | |
| 59 | + | pub label: &'static str, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] | |
| 63 | + | pub struct Diagnosis { | |
| 64 | + | pub code: Code, | |
| 65 | + | pub tone: Tone, | |
| 66 | + | /// One line: the tray's, and the heading everywhere else. | |
| 67 | + | pub title: String, | |
| 68 | + | /// Why, in a sentence. | |
| 69 | + | pub cause: Option<String>, | |
| 70 | + | /// What to do about it. | |
| 71 | + | pub fix: Option<String>, | |
| 72 | + | pub actions: Vec<Action>, | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// What the words are filled in with. | |
| 76 | + | #[derive(Clone, Debug, Default)] | |
| 77 | + | pub struct Facts { | |
| 78 | + | pub workspace: Option<String>, | |
| 79 | + | /// How jobs run, as `sandboxed`, `in Docker`, `on this computer`. | |
| 80 | + | pub backend: &'static str, | |
| 81 | + | pub machine: String, | |
| 82 | + | pub job: Option<String>, | |
| 83 | + | pub detail: Option<String>, | |
| 84 | + | pub exit_code: Option<i32>, | |
| 85 | + | pub wait_seconds: Option<u64>, | |
| 86 | + | /// The server's host: `g1t.sh`. | |
| 87 | + | pub server: String, | |
| 88 | + | pub platform: String, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | const START: Action = Action { id: "start", label: "Start" }; | |
| 92 | + | const TURN_ON: Action = Action { id: "start", label: "Turn on" }; | |
| 93 | + | const RETRY: Action = Action { id: "retry", label: "Retry" }; | |
| 94 | + | const RESTART: Action = Action { id: "restart", label: "Restart" }; | |
| 95 | + | const SIGN_IN: Action = Action { id: "sign_in", label: "Sign in" }; | |
| 96 | + | const CHOOSE_WORKSPACE: Action = Action { id: "choose_workspace", label: "Choose workspace" }; | |
| 97 | + | const NEW_WORKSPACE: Action = Action { id: "new_workspace", label: "Create a workspace" }; | |
| 98 | + | const CHOOSE_BACKEND: Action = Action { id: "choose_backend", label: "Choose how jobs run" }; | |
| 99 | + | const OPEN_DOCKER: Action = Action { id: "open_docker", label: "Open Docker" }; | |
| 100 | + | const GET_DOCKER: Action = Action { id: "get_docker", label: "Get Docker" }; | |
| 101 | + | const INSTALL_SYSBOX: Action = Action { id: "install_sysbox", label: "Install Sysbox" }; | |
| 102 | + | const LEARN_MORE: Action = Action { id: "learn_more", label: "Learn more" }; | |
| 103 | + | const VIEW_LOG: Action = Action { id: "view_log", label: "View log" }; | |
| 104 | + | const REPORT: Action = Action { id: "report", label: "Report" }; | |
| 105 | + | const VIEW_RUNNERS: Action = Action { id: "view_runners", label: "View on g1t" }; | |
| 106 | + | ||
| 107 | + | fn some(text: impl Into<String>) -> Option<String> { | |
| 108 | + | Some(text.into()) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// The catalogue. | |
| 112 | + | pub fn diagnose(code: Code, facts: &Facts) -> Diagnosis { | |
| 113 | + | let workspace = facts.workspace.as_deref().unwrap_or("your workspace"); | |
| 114 | + | let detail = facts.detail.clone(); | |
| 115 | + | let (tone, title, cause, fix, actions): (Tone, String, Option<String>, Option<String>, Vec<Action>) = match code { | |
| 116 | + | Code::Off => ( | |
| 117 | + | Tone::Off, | |
| 118 | + | "Not running: turned off".into(), | |
| 119 | + | some("Running jobs on this computer is turned off in g1t's settings."), | |
| 120 | + | some("Turn it on to run your workspace's self-hosted jobs here while g1t is open."), | |
| 121 | + | vec![TURN_ON], | |
| 122 | + | ), | |
| 123 | + | Code::Stopped => ( | |
| 124 | + | Tone::Off, | |
| 125 | + | "Not running: stopped".into(), | |
| 126 | + | some("You stopped the runner. It starts again when you press Start, or when g1t next opens."), | |
| 127 | + | None, | |
| 128 | + | vec![START], | |
| 129 | + | ), | |
| 130 | + | Code::Starting => (Tone::Working, "Starting".into(), detail, None, vec![]), | |
| 131 | + | Code::Registering => ( | |
| 132 | + | Tone::Working, | |
| 133 | + | format!("Registering with {workspace}"), | |
| 134 | + | some(format!("g1t is registering {} as a runner for {workspace}.", facts.machine)), | |
| 135 | + | some("Keep g1t open and signed in; it takes a few seconds."), | |
| 136 | + | vec![], | |
| 137 | + | ), | |
| 138 | + | Code::SignedOut => ( | |
| 139 | + | Tone::Warning, | |
| 140 | + | "Not running: signed out".into(), | |
| 141 | + | some("This computer isn't registered yet, and registering it needs you signed in to g1t."), | |
| 142 | + | some("Sign in, as an owner of the workspace the runner is for."), | |
| 143 | + | vec![SIGN_IN], | |
| 144 | + | ), | |
| 145 | + | Code::NoWorkspace => ( | |
| 146 | + | Tone::Warning, | |
| 147 | + | "Not running: no workspace to run jobs for".into(), | |
| 148 | + | some("Only a workspace's owners add runners to it, and you don't own one."), | |
| 149 | + | some("Ask an owner of your workspace to run it, or create a workspace of your own."), | |
| 150 | + | vec![NEW_WORKSPACE], | |
| 151 | + | ), | |
| 152 | + | Code::NotAllowed => ( | |
| 153 | + | Tone::Problem, | |
| 154 | + | format!("Couldn't register with {workspace}"), | |
| 155 | + | some(format!("You can't add runners to {workspace}: only its owners can.")), | |
| 156 | + | some(format!("Ask an owner of {workspace} to make you one, or choose a workspace you own.")), | |
| 157 | + | vec![CHOOSE_WORKSPACE], | |
| 158 | + | ), | |
| 159 | + | Code::RegisterFailed => ( | |
| 160 | + | Tone::Problem, | |
| 161 | + | format!("Couldn't register with {workspace}"), | |
| 162 | + | detail.or_else(|| some("g1t didn't make a registration for this computer.")), | |
| 163 | + | some("It tries again by itself in a few minutes."), | |
| 164 | + | vec![RETRY, VIEW_LOG], | |
| 165 | + | ), | |
| 166 | + | Code::Idle => ( | |
| 167 | + | Tone::Ok, | |
| 168 | + | "Waiting for jobs".into(), | |
| 169 | + | some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)), | |
| 170 | + | None, | |
| 171 | + | vec![VIEW_RUNNERS], | |
| 172 | + | ), | |
| 173 | + | Code::Busy => ( | |
| 174 | + | Tone::Working, | |
| 175 | + | format!("Running {}", facts.job.as_deref().unwrap_or("a job")), | |
| 176 | + | some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)), | |
| 177 | + | None, | |
| 178 | + | vec![VIEW_RUNNERS], | |
| 179 | + | ), | |
| 180 | + | Code::NoDocker => { | |
| 181 | + | let app = match facts.platform.as_str() { | |
| 182 | + | "windows" | "macos" => "Docker Desktop", | |
| 183 | + | _ => "Docker", | |
| 184 | + | }; | |
| 185 | + | ( | |
| 186 | + | Tone::Problem, | |
| 187 | + | "Not running: Docker isn't running".into(), | |
| 188 | + | some(format!("Running jobs {} needs Docker, and Docker isn't running on this computer, or isn't installed.", facts.backend)), | |
| 189 | + | some(format!("Start {app}, or install it. The runner starts by itself once Docker answers.")), | |
| 190 | + | if app == "Docker Desktop" { vec![OPEN_DOCKER, GET_DOCKER] } else { vec![GET_DOCKER] }, | |
| 191 | + | ) | |
| 192 | + | } | |
| 193 | + | Code::NoSandbox => ( | |
| 194 | + | Tone::Warning, | |
| 195 | + | "Not running: a sandbox isn't available on this computer yet".into(), | |
| 196 | + | some("The sandbox keeps everything a job does away from this computer. It isn't built for this system yet."), | |
| 197 | + | some("Wait for it, or choose to run jobs in Docker or directly on this computer. Neither is a sandbox: settings say what each can reach."), | |
| 198 | + | vec![LEARN_MORE, CHOOSE_BACKEND], | |
| 199 | + | ), | |
| 200 | + | Code::NoSysbox => ( | |
| 201 | + | Tone::Problem, | |
| 202 | + | "Not running: Sysbox isn't installed".into(), | |
| 203 | + | some("The sandbox on Linux is the runner's image under Sysbox, and this computer's Docker doesn't have Sysbox."), | |
| 204 | + | some("Install Sysbox; the runner starts by itself once Docker has it."), | |
| 205 | + | vec![INSTALL_SYSBOX, CHOOSE_BACKEND], | |
| 206 | + | ), | |
| 207 | + | Code::Updating => (Tone::Working, "Updating".into(), detail, some("The runner starts again as soon as it's done."), vec![]), | |
| 208 | + | Code::UpdateFailed => ( | |
| 209 | + | Tone::Warning, | |
| 210 | + | "Update failed".into(), | |
| 211 | + | detail, | |
| 212 | + | some("The runner keeps running the version it has. Try again, or report it if it keeps happening."), | |
| 213 | + | vec![RETRY, REPORT], | |
| 214 | + | ), | |
| 215 | + | Code::Offline => ( | |
| 216 | + | Tone::Warning, | |
| 217 | + | format!("Offline: {} unreachable", facts.server), | |
| 218 | + | some(format!("This computer can't reach {}, so the runner can't take jobs.", facts.server)), | |
| 219 | + | some("Check the connection. The runner carries on by itself when it's back."), | |
| 220 | + | vec![RETRY], | |
| 221 | + | ), | |
| 222 | + | Code::Crashed => ( | |
| 223 | + | Tone::Problem, | |
| 224 | + | match facts.exit_code { | |
| 225 | + | Some(exit) => format!("Runner stopped (exit code {exit})"), | |
| 226 | + | None => "Runner stopped".into(), | |
| 227 | + | }, | |
| 228 | + | detail, | |
| 229 | + | Some(match facts.wait_seconds { | |
| 230 | + | Some(wait) => format!("It starts again by itself in {wait} seconds. Restart now, or look at its log."), | |
| 231 | + | None => "Restart it, or look at its log.".into(), | |
| 232 | + | }), | |
| 233 | + | vec![RESTART, VIEW_LOG], | |
| 234 | + | ), | |
| 235 | + | }; | |
| 236 | + | Diagnosis { code, tone, title, cause, fix, actions } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// What the site says of this computer while g1t is closed on it. | |
| 240 | + | pub fn closed(facts: &Facts) -> Diagnosis { | |
| 241 | + | Diagnosis { | |
| 242 | + | code: Code::Off, | |
| 243 | + | tone: Tone::Off, | |
| 244 | + | title: format!("g1t is closed on {}", facts.machine), | |
| 245 | + | cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)), | |
| 246 | + | fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)), | |
| 247 | + | actions: vec![], | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | #[cfg(test)] | |
| 252 | + | mod tests { | |
| 253 | + | use super::*; | |
| 254 | + | ||
| 255 | + | fn facts() -> Facts { | |
| 256 | + | Facts { workspace: Some("acme".into()), backend: "sandboxed", machine: "build-box".into(), server: "g1t.sh".into(), platform: "windows".into(), ..Facts::default() } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | #[test] | |
| 260 | + | fn every_failure_says_why_and_how_to_fix_it() { | |
| 261 | + | for code in [Code::SignedOut, Code::NoWorkspace, Code::NotAllowed, Code::RegisterFailed, Code::NoDocker, Code::NoSandbox, Code::NoSysbox, Code::UpdateFailed, Code::Offline, Code::Crashed] { | |
| 262 | + | let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() }); | |
| 263 | + | assert!(diagnosis.cause.is_some(), "{code:?} has no cause"); | |
| 264 | + | assert!(diagnosis.fix.is_some(), "{code:?} has no fix"); | |
| 265 | + | assert!(!diagnosis.actions.is_empty(), "{code:?} has no action"); | |
| 266 | + | assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}"); | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn the_words_name_the_workspace_the_machine_and_the_job() { | |
| 272 | + | let not_allowed = diagnose(Code::NotAllowed, &facts()); | |
| 273 | + | assert_eq!(not_allowed.cause.as_deref(), Some("You can't add runners to acme: only its owners can.")); | |
| 274 | + | assert_eq!(not_allowed.actions[0].id, "choose_workspace"); | |
| 275 | + | let busy = diagnose(Code::Busy, &Facts { job: Some("Build (acme/web)".into()), ..facts() }); | |
| 276 | + | assert_eq!(busy.title, "Running Build (acme/web)"); | |
| 277 | + | assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box.")); | |
| 278 | + | let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() }); | |
| 279 | + | assert_eq!(crashed.title, "Runner stopped (exit code 101)"); | |
| 280 | + | assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]); | |
| 281 | + | assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker"); | |
| 282 | + | assert_eq!(closed(&facts()).title, "g1t is closed on build-box"); | |
| 283 | + | } | |
| 284 | + | } |
| 1 | + | //! The one place the app meets `g1t-runner sandbox`: the launcher that | |
| 2 | + | //! makes a Linux VM of its own on this computer (Hyper-V or WSL on | |
| 3 | + | //! Windows, Apple's Virtualization framework on macOS, Sysbox or KVM on | |
| 4 | + | //! Linux) and runs the runner inside it, so jobs are containers inside the | |
| 5 | + | //! runner's sandbox and none of it reaches this computer | |
| 6 | + | //! (`nested-container` isolation). | |
| 7 | + | //! | |
| 8 | + | //! It is being built in crates/runner. Until it is released, `READY` is | |
| 9 | + | //! false: the Sandboxed backend uses the runner's image under Sysbox where | |
| 10 | + | //! this computer's Docker has it (container.rs), and says a sandbox isn't | |
| 11 | + | //! available everywhere else. The commands below are the expected shape | |
| 12 | + | //! (`sandbox up|down|update`, configured like `register-and-run`, from | |
| 13 | + | //! `G1T_RUNNER_*` in the environment); when the launcher lands, this file | |
| 14 | + | //! is the only one that changes. | |
| 15 | + | ||
| 16 | + | use std::path::Path; | |
| 17 | + | use std::process::{Command, Stdio}; | |
| 18 | + | ||
| 19 | + | use super::backend::quiet; | |
| 20 | + | ||
| 21 | + | /// Whether the released `g1t-runner` has its sandbox launcher. | |
| 22 | + | pub const READY: bool = false; | |
| 23 | + | ||
| 24 | + | /// The launcher's own folder under the app's runner folder: its VM and the | |
| 25 | + | /// runner's registration inside it are kept there. | |
| 26 | + | pub const FOLDER: &str = "sandbox"; | |
| 27 | + | ||
| 28 | + | fn sandbox(binary: &Path, action: &str, dir: &Path) -> Command { | |
| 29 | + | let mut command = quiet(binary); | |
| 30 | + | command.args(["sandbox", action, "--dir"]).arg(dir); | |
| 31 | + | command | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Makes the sandbox if it is not there, starts it, and runs the runner | |
| 35 | + | /// inside it until `down`: a child whose output is the runner's log. | |
| 36 | + | pub fn up(binary: &Path, dir: &Path) -> Command { | |
| 37 | + | let mut command = sandbox(binary, "up", dir); | |
| 38 | + | command.stdout(Stdio::piped()).stderr(Stdio::piped()); | |
| 39 | + | command | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// Stops the runner and the sandbox around it, waiting for neither job nor VM to linger. | |
| 43 | + | pub fn down(binary: &Path, dir: &Path) { | |
| 44 | + | let _ = sandbox(binary, "down", dir).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Brings the sandbox's image to the release of `binary`; true when it changed. | |
| 48 | + | pub fn update(binary: &Path, dir: &Path) -> bool { | |
| 49 | + | sandbox(binary, "update", dir).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|status| status.success()) | |
| 50 | + | } |
| 1 | + | //! What the runner's log says, read a line at a time. `g1t-runner` writes | |
| 2 | + | //! one line per thing it does (crates/runner/src/selfhosted/mod.rs), the | |
| 3 | + | //! same in every backend: in a sandbox, in a container, or on this | |
| 4 | + | //! computer. The app reads its state from those lines rather than asking | |
| 5 | + | //! g1t, so the tray is right even while g1t cannot be reached. | |
| 6 | + | ||
| 7 | + | /// What one line of the log means for the app. | |
| 8 | + | #[derive(Debug, PartialEq, Eq)] | |
| 9 | + | pub enum Event { | |
| 10 | + | /// Registered and polling for work: `g1t-runner 0.2.3: name (labels) is listening for work from …`. | |
| 11 | + | Listening { version: String }, | |
| 12 | + | /// It took work: `Took workflow build (acme/web).` | |
| 13 | + | Took { name: String, repo: String }, | |
| 14 | + | /// The work it had ended: `workflow build ended (0).` | |
| 15 | + | Ended, | |
| 16 | + | /// g1t does not know this runner any more: it was removed, or its | |
| 17 | + | /// credential was. The registration is gone and a new one is needed. | |
| 18 | + | Forgotten, | |
| 19 | + | /// The registration token was refused: expired, or not one. | |
| 20 | + | TokenRefused, | |
| 21 | + | /// g1t could not be reached; the runner tries again by itself. | |
| 22 | + | Offline, | |
| 23 | + | /// Something went wrong that stops the runner: the program's own error line. | |
| 24 | + | Failed(String), | |
| 25 | + | /// Anything else, kept only in the log file. | |
| 26 | + | Other, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// The line without the time the runner put in front of it (`2026-10-12T08:00:00Z …`). | |
| 30 | + | pub fn message(line: &str) -> &str { | |
| 31 | + | let line = line.trim(); | |
| 32 | + | match line.split_once(' ') { | |
| 33 | + | Some((first, rest)) if first.len() == 20 && first.starts_with("20") && first.ends_with('Z') => rest, | |
| 34 | + | _ => line, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | pub fn read(line: &str) -> Event { | |
| 39 | + | let text = message(line); | |
| 40 | + | if let Some(rest) = text.strip_prefix("g1t-runner ") | |
| 41 | + | && let Some((version, after)) = rest.split_once(": ") | |
| 42 | + | && after.contains(" is listening for work from ") | |
| 43 | + | { | |
| 44 | + | return Event::Listening { version: version.to_owned() }; | |
| 45 | + | } | |
| 46 | + | if let Some(rest) = text.strip_prefix("Took ") | |
| 47 | + | && let Some(rest) = rest.strip_suffix(").") | |
| 48 | + | && let Some((head, repo)) = rest.rsplit_once(" (") | |
| 49 | + | { | |
| 50 | + | let name = head.split_once(' ').map_or(head, |(_kind, name)| name); | |
| 51 | + | return Event::Took { name: name.to_owned(), repo: repo.to_owned() }; | |
| 52 | + | } | |
| 53 | + | if (text.starts_with("workflow ") || text.starts_with("agent ")) && text.contains(" ended (") { | |
| 54 | + | return Event::Ended; | |
| 55 | + | } | |
| 56 | + | if text.contains("g1t no longer knows this runner") || text.contains("This runner was removed from g1t") { | |
| 57 | + | return Event::Forgotten; | |
| 58 | + | } | |
| 59 | + | if text.contains("could not reach g1t") && !text.starts_with("g1t-runner: ") { | |
| 60 | + | return Event::Offline; | |
| 61 | + | } | |
| 62 | + | if let Some(error) = text.strip_prefix("g1t-runner: ") { | |
| 63 | + | let lower = error.to_ascii_lowercase(); | |
| 64 | + | if lower.contains("token") && (lower.contains("not valid") || lower.contains("expired") || lower.contains("invalid")) { | |
| 65 | + | return Event::TokenRefused; | |
| 66 | + | } | |
| 67 | + | return Event::Failed(error.to_owned()); | |
| 68 | + | } | |
| 69 | + | Event::Other | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[cfg(test)] | |
| 73 | + | mod tests { | |
| 74 | + | use super::*; | |
| 75 | + | ||
| 76 | + | #[test] | |
| 77 | + | fn the_runner_listening_is_read_with_its_version() { | |
| 78 | + | let line = "2026-10-12T08:00:00Z g1t-runner 0.2.3: syntaqx-win (self-hosted, windows, x64, desktop) is listening for work from https://g1t.sh. Isolation: host: jobs run directly on this machine, unsandboxed."; | |
| 79 | + | assert_eq!(read(line), Event::Listening { version: "0.2.3".into() }); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | #[test] | |
| 83 | + | fn work_taken_and_ended_is_read() { | |
| 84 | + | assert_eq!( | |
| 85 | + | read("2026-10-12T08:00:01Z Took workflow Build the installer (flagon-io/g1t)."), | |
| 86 | + | Event::Took { name: "Build the installer".into(), repo: "flagon-io/g1t".into() } | |
| 87 | + | ); | |
| 88 | + | assert_eq!(read("2026-10-12T08:09:00Z workflow Build the installer ended (0)."), Event::Ended); | |
| 89 | + | assert_eq!(read("agent Review ended (1)."), Event::Ended); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[test] | |
| 93 | + | fn losing_the_registration_is_read() { | |
| 94 | + | assert_eq!(read("g1t-runner: g1t no longer knows this runner (401 unauthorized). Register it again."), Event::Forgotten); | |
| 95 | + | assert_eq!(read("2026-10-12T08:00:00Z This runner was removed from g1t; stopping."), Event::Forgotten); | |
| 96 | + | assert_eq!(read("g1t-runner: The registration token is not valid, or has expired."), Event::TokenRefused); | |
| 97 | + | assert_eq!(read("g1t-runner: could not make /data"), Event::Failed("could not make /data".into())); | |
| 98 | + | assert_eq!(read("2026-10-12T08:00:00Z Registered syntaqx-win in acme."), Event::Other); | |
| 99 | + | assert_eq!(message("plain"), "plain"); | |
| 100 | + | assert_eq!(read("2026-10-12T08:00:00Z could not reach g1t: dns error; trying again in 4s"), Event::Offline); | |
| 101 | + | } | |
| 102 | + | } |
A large diff (1,053 lines) is not shown until you ask for it.
| 1 | + | //! `g1t-runner` itself, kept in the app's data folder and run as a child | |
| 2 | + | //! of the app: for the Docker and On this computer backends, and for the | |
| 3 | + | //! sandbox launcher, which is the same program. | |
| 4 | + | //! | |
| 5 | + | //! The app installs and updates it from the server's releases: the | |
| 6 | + | //! newest one's manifest is `<server>/downloads/runner/latest.json`, which | |
| 7 | + | //! names each platform's file and its SHA-256, and a download whose | |
| 8 | + | //! SHA-256 is not the manifest's is thrown away. The runner's own | |
| 9 | + | //! updating is turned off (`G1T_RUNNER_NO_AUTO_UPDATE`), so only the app | |
| 10 | + | //! replaces it, and only while it runs nothing. | |
| 11 | + | ||
| 12 | + | use std::collections::BTreeMap; | |
| 13 | + | use std::path::{Path, PathBuf}; | |
| 14 | + | use std::process::{Child, Stdio}; | |
| 15 | + | use std::time::Duration; | |
| 16 | + | ||
| 17 | + | use serde::Deserialize; | |
| 18 | + | use sha2::{Digest, Sha256}; | |
| 19 | + | use url::Url; | |
| 20 | + | ||
| 21 | + | use super::backend::quiet; | |
| 22 | + | ||
| 23 | + | #[derive(Deserialize)] | |
| 24 | + | pub struct File { | |
| 25 | + | pub name: String, | |
| 26 | + | pub sha256: String, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | #[derive(Deserialize)] | |
| 30 | + | pub struct Manifest { | |
| 31 | + | pub version: String, | |
| 32 | + | pub files: BTreeMap<String, File>, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// This computer's platform, as releases name it. | |
| 36 | + | pub fn platform() -> String { | |
| 37 | + | let os = match std::env::consts::OS { | |
| 38 | + | "macos" => "macos", | |
| 39 | + | "windows" => "windows", | |
| 40 | + | _ => "linux", | |
| 41 | + | }; | |
| 42 | + | let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" }; | |
| 43 | + | format!("{os}-{arch}") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Where the program is kept. | |
| 47 | + | pub fn binary(folder: &Path) -> PathBuf { | |
| 48 | + | folder.join("bin").join(if cfg!(windows) { "g1t-runner.exe" } else { "g1t-runner" }) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// The version installed, as the manifest named it. | |
| 52 | + | pub fn installed(folder: &Path) -> Option<String> { | |
| 53 | + | binary(folder).exists().then(|| std::fs::read_to_string(folder.join("bin").join("version")).ok()).flatten().map(|v| v.trim().to_owned()) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// `a.b.c` newer than `x.y.z`. | |
| 57 | + | pub fn newer(candidate: &str, current: &str) -> bool { | |
| 58 | + | let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() }; | |
| 59 | + | parts(candidate) > parts(current) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | pub fn sha256(bytes: &[u8]) -> String { | |
| 63 | + | Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect() | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | fn downloads(server: &Url) -> String { | |
| 67 | + | format!("{}downloads/runner", server.as_str()) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | fn get(url: &str) -> Result<Vec<u8>, String> { | |
| 71 | + | tauri::async_runtime::block_on(async { | |
| 72 | + | let client = reqwest::Client::builder().timeout(Duration::from_secs(300)).build().map_err(|e| e.to_string())?; | |
| 73 | + | let response = client.get(url).send().await.map_err(|e| e.to_string())?; | |
| 74 | + | if !response.status().is_success() { | |
| 75 | + | return Err(format!("{url} answered {}", response.status())); | |
| 76 | + | } | |
| 77 | + | response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|e| e.to_string()) | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// The newest release's manifest. | |
| 82 | + | pub fn latest(server: &Url) -> Result<Manifest, String> { | |
| 83 | + | let bytes = get(&format!("{}/latest.json", downloads(server)))?; | |
| 84 | + | serde_json::from_slice(&bytes).map_err(|e| format!("the runner's release manifest does not read: {e}")) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Downloads a release's program for this platform and checks it against | |
| 88 | + | /// the manifest's SHA-256. | |
| 89 | + | pub fn download(server: &Url, manifest: &Manifest) -> Result<Vec<u8>, String> { | |
| 90 | + | let file = manifest.files.get(&platform()).ok_or_else(|| format!("g1t-runner {} has no build for {}", manifest.version, platform()))?; | |
| 91 | + | let bytes = get(&format!("{}/{}/{}", downloads(server), manifest.version, file.name))?; | |
| 92 | + | if sha256(&bytes) != file.sha256.to_ascii_lowercase() { | |
| 93 | + | return Err("the download's SHA-256 isn't the release's, so it was thrown away".into()); | |
| 94 | + | } | |
| 95 | + | Ok(bytes) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Puts a checked program in place. The runner must not be running: | |
| 99 | + | /// Windows will not replace a program that is. | |
| 100 | + | pub fn put(folder: &Path, bytes: &[u8], version: &str) -> Result<(), String> { | |
| 101 | + | let path = binary(folder); | |
| 102 | + | let dir = path.parent().unwrap_or(folder); | |
| 103 | + | std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; | |
| 104 | + | let new = path.with_extension("new"); | |
| 105 | + | std::fs::write(&new, bytes).map_err(|e| e.to_string())?; | |
| 106 | + | #[cfg(unix)] | |
| 107 | + | { | |
| 108 | + | use std::os::unix::fs::PermissionsExt; | |
| 109 | + | std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?; | |
| 110 | + | } | |
| 111 | + | std::fs::rename(&new, &path).map_err(|e| format!("could not put g1t-runner in place: {e}"))?; | |
| 112 | + | std::fs::write(dir.join("version"), version).map_err(|e| e.to_string()) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Starts `register-and-run`, which registers the first time (from | |
| 116 | + | /// `G1T_RUNNER_*` in the environment the caller sets) and then runs. | |
| 117 | + | pub fn spawn(binary: &Path, dir: &Path, envs: &[(&str, String)]) -> std::io::Result<Child> { | |
| 118 | + | let mut command = quiet(binary); | |
| 119 | + | command.arg("register-and-run").arg("--dir").arg(dir); | |
| 120 | + | // What the app's own environment may have set for another runner is not this one's. | |
| 121 | + | for (name, _) in std::env::vars_os() { | |
| 122 | + | if name.to_string_lossy().starts_with("G1T_RUNNER_") { | |
| 123 | + | command.env_remove(&name); | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | command.envs(envs.iter().map(|(name, value)| (*name, value.as_str()))); | |
| 127 | + | command.stdout(Stdio::piped()).stderr(Stdio::piped()); | |
| 128 | + | // A group of its own, so stopping it reaches what it started. | |
| 129 | + | #[cfg(unix)] | |
| 130 | + | std::os::unix::process::CommandExt::process_group(&mut command, 0); | |
| 131 | + | command.spawn() | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// Unregisters the runner whose configuration is in `dir`, and forgets it. | |
| 135 | + | pub fn forget(binary: &Path, dir: &Path) { | |
| 136 | + | if binary.exists() && dir.join("config.json").exists() { | |
| 137 | + | let _ = quiet(binary).arg("remove").arg("--dir").arg(dir).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 138 | + | } | |
| 139 | + | let _ = std::fs::remove_file(dir.join("config.json")); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// Stops the runner and what it started: the job it ran in host mode | |
| 143 | + | /// included. A job cut short this way fails on g1t. | |
| 144 | + | pub fn stop(child: &mut Child) { | |
| 145 | + | let pid = child.id().to_string(); | |
| 146 | + | if cfg!(windows) { | |
| 147 | + | let _ = quiet("taskkill").args(["/T", "/F", "/PID", &pid]).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 148 | + | } else { | |
| 149 | + | let _ = quiet("kill").args(["-TERM", &format!("-{pid}")]).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 150 | + | for _ in 0..40 { | |
| 151 | + | if matches!(child.try_wait(), Ok(Some(_))) { | |
| 152 | + | return; | |
| 153 | + | } | |
| 154 | + | std::thread::sleep(Duration::from_millis(250)); | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | let _ = child.kill(); | |
| 158 | + | let _ = child.wait(); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// Removes the containers, networks and volumes of the jobs of the runner | |
| 162 | + | /// registered in `dir`, as `g1t-runner` does itself when it starts: each | |
| 163 | + | /// carries the label `sh.g1t.runner=<its id>`. | |
| 164 | + | pub fn sweep(dir: &Path) { | |
| 165 | + | #[derive(Deserialize)] | |
| 166 | + | struct Registered { | |
| 167 | + | runner: String, | |
| 168 | + | } | |
| 169 | + | let Some(registered) = std::fs::read(dir.join("config.json")).ok().and_then(|bytes| serde_json::from_slice::<Registered>(&bytes).ok()) else { return }; | |
| 170 | + | let label = format!("label=sh.g1t.runner={}", registered.runner); | |
| 171 | + | for (list, remove) in [ | |
| 172 | + | (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]), | |
| 173 | + | (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]), | |
| 174 | + | (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]), | |
| 175 | + | ] { | |
| 176 | + | let Ok(out) = quiet("docker").args(&list).stderr(Stdio::null()).output() else { return }; | |
| 177 | + | let ids: Vec<String> = String::from_utf8_lossy(&out.stdout).split_whitespace().map(str::to_owned).collect(); | |
| 178 | + | if !ids.is_empty() { | |
| 179 | + | let _ = quiet("docker").args(&remove).args(&ids).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// A runner the app started before and did not stop (it was ended | |
| 185 | + | /// without quitting): stopped, if that process is still `g1t-runner`. | |
| 186 | + | pub fn reap(pid: u32) { | |
| 187 | + | let pid = pid.to_string(); | |
| 188 | + | if cfg!(windows) { | |
| 189 | + | let _ = quiet("taskkill") | |
| 190 | + | .args(["/T", "/F", "/FI", "IMAGENAME eq g1t-runner.exe", "/PID", &pid]) | |
| 191 | + | .stdout(Stdio::null()) | |
| 192 | + | .stderr(Stdio::null()) | |
| 193 | + | .status(); | |
| 194 | + | } else { | |
| 195 | + | let named = quiet("ps").args(["-p", &pid, "-o", "comm="]).output().is_ok_and(|out| String::from_utf8_lossy(&out.stdout).trim().ends_with("g1t-runner")); | |
| 196 | + | if named { | |
| 197 | + | let _ = quiet("kill").args(["-TERM", &pid]).status(); | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[cfg(test)] | |
| 203 | + | mod tests { | |
| 204 | + | use super::*; | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn releases_are_named_and_compared_as_the_runner_does() { | |
| 208 | + | assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64"].contains(&platform().as_str())); | |
| 209 | + | assert!(newer("0.10.0", "0.9.9")); | |
| 210 | + | assert!(!newer("0.2.0", "0.2.0")); | |
| 211 | + | assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); | |
| 212 | + | let manifest: Manifest = serde_json::from_str(r#"{"version":"0.2.3","agent_image":null,"files":{"windows-x64":{"name":"g1t-runner-windows-x64.exe","sha256":"ab"}}}"#).unwrap(); | |
| 213 | + | assert_eq!(manifest.files["windows-x64"].name, "g1t-runner-windows-x64.exe"); | |
| 214 | + | assert_eq!(downloads(&Url::parse("https://g1t.sh/").unwrap()), "https://g1t.sh/downloads/runner"); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 218 | + | fn nothing_is_installed_in_an_empty_folder() { | |
| 219 | + | let dir = std::env::temp_dir().join(format!("g1t-desktop-runner-{}", std::process::id())); | |
| 220 | + | assert_eq!(installed(&dir), None); | |
| 221 | + | assert!(binary(&dir).ends_with(if cfg!(windows) { "bin/g1t-runner.exe" } else { "bin/g1t-runner" })); | |
| 222 | + | } | |
| 223 | + | } |
| 1 | 1 | //! The app's own settings: how it starts, what closing the window does, | |
| 2 | − | //! how it tells the person something arrived, its title bar, zoom and | |
| 3 | − | //! updates. They live in their own window (`launcher/settings.html`), | |
| 2 | + | //! how it tells the person something arrived, its title bar, zoom, | |
| 3 | + | //! updates, and the runner. They live in their own window (`launcher/settings.html`), | |
| 4 | 4 | //! opened from the menu, the tray, or the site's account menu, and are | |
| 5 | 5 | //! kept with the server in `desktop.json` (config.rs). Whether g1t opens | |
| 6 | 6 | //! when someone signs in to their computer is kept by the system itself, | |
| ⋯ | |||
| 42 | 42 | /// The site's zoom, 1 for as it is. | |
| 43 | 43 | pub zoom: f64, | |
| 44 | 44 | /// Updates download by themselves; a person can still check from the menu. | |
| 45 | + | /// The runner's own updates follow it too (runner/mod.rs). | |
| 45 | 46 | pub auto_update: bool, | |
| 47 | + | /// Runs the workspace's self-hosted jobs on this computer while g1t is | |
| 48 | + | /// open (runner/mod.rs). On unless someone turns it off. | |
| 49 | + | pub runner: bool, | |
| 50 | + | /// How the runner runs: sandboxed unless someone chooses otherwise. | |
| 51 | + | pub runner_backend: crate::runner::Backend, | |
| 52 | + | /// The workspace it runs for; the one the person is in when it first registers. | |
| 53 | + | pub runner_workspace: Option<String>, | |
| 46 | 54 | } | |
| 47 | 55 | ||
| 48 | 56 | impl Default for Settings { | |
| 49 | 57 | fn default() -> Self { | |
| 50 | − | Self { start_hidden: true, close_to_tray: true, notifications: true, flash: true, badge: true, system_title_bar: false, zoom: 1.0, auto_update: true } | |
| 58 | + | Self { | |
| 59 | + | start_hidden: true, | |
| 60 | + | close_to_tray: true, | |
| 61 | + | notifications: true, | |
| 62 | + | flash: true, | |
| 63 | + | badge: true, | |
| 64 | + | system_title_bar: false, | |
| 65 | + | zoom: 1.0, | |
| 66 | + | auto_update: true, | |
| 67 | + | runner: true, | |
| 68 | + | runner_backend: crate::runner::Backend::Sandboxed, | |
| 69 | + | runner_workspace: None, | |
| 70 | + | } | |
| 51 | 71 | } | |
| 52 | 72 | } | |
| 53 | 73 | ||
| ⋯ | |||
| 76 | 96 | pub title_bar_changed: bool, | |
| 77 | 97 | /// Whether this window draws g1t's own title bar, which the page then draws (settings.html). | |
| 78 | 98 | pub custom_frame: bool, | |
| 99 | + | /// The runner's state, diagnosis and recent log. | |
| 100 | + | pub runner_view: crate::runner::View, | |
| 79 | 101 | } | |
| 80 | 102 | ||
| 81 | 103 | pub fn view(app: &AppHandle) -> View { | |
| ⋯ | |||
| 89 | 111 | checked_at: crate::updates::checked_at(app), | |
| 90 | 112 | title_bar_changed: crate::frame::started(app) != crate::frame::custom(app), | |
| 91 | 113 | custom_frame: crate::frame::started(app), | |
| 114 | + | runner_view: crate::runner::view(app), | |
| 92 | 115 | } | |
| 93 | 116 | } | |
| 94 | 117 | ||
| ⋯ | |||
| 103 | 126 | if before.badge != settings.badge { | |
| 104 | 127 | crate::badge::refresh(app); | |
| 105 | 128 | } | |
| 129 | + | if (before.runner, before.runner_backend, &before.runner_workspace) != (settings.runner, settings.runner_backend, &settings.runner_workspace) { | |
| 130 | + | crate::runner::settings_changed(app); | |
| 131 | + | } | |
| 106 | 132 | Ok(()) | |
| 107 | 133 | } | |
| 108 | 134 | ||
| ⋯ | |||
| 188 | 214 | assert!(!settings.close_to_tray); | |
| 189 | 215 | assert!(settings.notifications); | |
| 190 | 216 | assert_eq!(settings.zoom, 1.0); | |
| 217 | + | // The runner is on, and sandboxed, for someone who never chose. | |
| 218 | + | assert!(settings.runner); | |
| 219 | + | assert_eq!(settings.runner_backend, crate::runner::Backend::Sandboxed); | |
| 220 | + | let chosen: Settings = serde_json::from_str(r#"{"runner":false,"runnerBackend":"host","runnerWorkspace":"acme"}"#).unwrap(); | |
| 221 | + | assert!(!chosen.runner); | |
| 222 | + | assert_eq!(chosen.runner_backend, crate::runner::Backend::Host); | |
| 223 | + | assert_eq!(chosen.runner_workspace.as_deref(), Some("acme")); | |
| 191 | 224 | } | |
| 192 | 225 | ||
| 193 | 226 | #[test] | |
| 1 | 1 | //! The tray icon and its menu, and showing and hiding the window. Closing | |
| 2 | 2 | //! the window hides it, unless settings say closing quits; g1t keeps | |
| 3 | 3 | //! running for notifications, and the tray's Open, or the icon, brings it | |
| 4 | − | //! back. Quit is here too. The menu's rows are handled with the app | |
| 4 | + | //! back. The runner's state is a row of its own, which opens settings, | |
| 5 | + | //! with Start or Stop under it (runner/mod.rs). Quit is here too. The menu's rows are handled with the app | |
| 5 | 6 | //! menu's (menu.rs). | |
| 6 | 7 | ||
| 7 | 8 | use std::sync::Mutex; | |
| ⋯ | |||
| 18 | 19 | /// the Help menu's: off until one is. | |
| 19 | 20 | static RESTART: Mutex<Vec<MenuItem<Wry>>> = Mutex::new(Vec::new()); | |
| 20 | 21 | ||
| 22 | + | /// The runner's rows: what it is doing, and Start or Stop. | |
| 23 | + | static RUNNER: Mutex<Option<(MenuItem<Wry>, MenuItem<Wry>)>> = Mutex::new(None); | |
| 24 | + | ||
| 21 | 25 | /// A row that restarts into the update, kept so `update_ready` can turn it on. | |
| 22 | 26 | pub fn restart_item(app: &AppHandle) -> tauri::Result<MenuItem<Wry>> { | |
| 23 | 27 | let item = MenuItem::with_id(app, "restart", "Restart to finish updating", false, None::<&str>)?; | |
| ⋯ | |||
| 29 | 33 | let open = MenuItem::with_id(app, "open", "Open g1t", true, None::<&str>)?; | |
| 30 | 34 | let check = MenuItem::with_id(app, "check", "Check for updates…", true, None::<&str>)?; | |
| 31 | 35 | let restart = restart_item(app)?; | |
| 36 | + | let runner_view = crate::runner::view(app); | |
| 37 | + | let runner = MenuItem::with_id(app, "runner-status", crate::runner::headline(&runner_view), true, None::<&str>)?; | |
| 38 | + | let runner_toggle = MenuItem::with_id(app, "runner-toggle", toggle_text(&runner_view), true, None::<&str>)?; | |
| 39 | + | *RUNNER.lock().unwrap() = Some((runner.clone(), runner_toggle.clone())); | |
| 32 | 40 | let settings = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; | |
| 33 | 41 | let server = MenuItem::with_id(app, "server", "Use another g1t…", true, None::<&str>)?; | |
| 34 | 42 | let quit = MenuItem::with_id(app, "quit", "Quit g1t", true, None::<&str>)?; | |
| ⋯ | |||
| 37 | 45 | &[ | |
| 38 | 46 | &open, | |
| 39 | 47 | &PredefinedMenuItem::separator(app)?, | |
| 48 | + | &runner, | |
| 49 | + | &runner_toggle, | |
| 50 | + | &PredefinedMenuItem::separator(app)?, | |
| 40 | 51 | &check, | |
| 41 | 52 | &restart, | |
| 42 | 53 | &PredefinedMenuItem::separator(app)?, | |
| ⋯ | |||
| 62 | 73 | Ok(()) | |
| 63 | 74 | } | |
| 64 | 75 | ||
| 76 | + | fn toggle_text(view: &crate::runner::View) -> &'static str { | |
| 77 | + | if view.enabled && !view.paused { "Stop running jobs" } else { "Start running jobs" } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// The runner changed: its rows, and the icon's tooltip, say what it is doing now. | |
| 81 | + | pub fn runner_changed(app: &AppHandle, view: &crate::runner::View) { | |
| 82 | + | if let Some((status, toggle)) = RUNNER.lock().unwrap().as_ref() { | |
| 83 | + | let _ = status.set_text(crate::runner::headline(view)); | |
| 84 | + | let _ = toggle.set_text(toggle_text(view)); | |
| 85 | + | } | |
| 86 | + | if let Some(tray) = app.tray_by_id(TRAY) { | |
| 87 | + | let _ = tray.set_tooltip(Some(format!("g1t · {}", crate::runner::headline(view)))); | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 65 | 91 | /// The update is downloaded: the menus say so, and offer the restart. | |
| 66 | 92 | pub fn update_ready(version: &str) { | |
| 67 | 93 | for item in RESTART.lock().unwrap().iter() { | |
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | 122 | /// The tray's restart: installs the update and starts the new version. | |
| 123 | + | /// The runner stops first, since on Windows the installer ends g1t. | |
| 123 | 124 | pub fn restart(app: &AppHandle) { | |
| 125 | + | if ready(app).is_none() { | |
| 126 | + | return; | |
| 127 | + | } | |
| 128 | + | crate::runner::shutdown(app); | |
| 124 | 129 | if install(app) { | |
| 125 | 130 | app.restart(); | |
| 126 | 131 | } | |
| 127 | 132 | } | |
| 128 | 133 | ||
| 129 | − | /// Quit, installing a downloaded update on the way out. | |
| 134 | + | /// Quit, stopping the runner and installing a downloaded update on the way out. | |
| 130 | 135 | pub fn quit(app: &AppHandle) { | |
| 136 | + | crate::runner::shutdown(app); | |
| 131 | 137 | install(app); | |
| 132 | 138 | app.exit(0); | |
| 133 | 139 | } |
| 1 | 1 | --- | |
| 2 | 2 | title: The desktop app | |
| 3 | − | description: g1t in a window of its own, for Windows, macOS and Linux, with notifications from your system, a badge on the icon, g1t:// links, and updates that install themselves. | |
| 3 | + | description: g1t in a window of its own, for Windows, macOS and Linux, with notifications from your system, a badge on the icon, g1t:// links, a runner for your workspace's jobs, and updates that install themselves. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | The desktop app is g1t in a window of its own. Everything in it is the | |
| ⋯ | |||
| 17 | 17 | - **Updates that install themselves**, signed by g1t, in the background. | |
| 18 | 18 | - **Your own g1t**, if you [run one](/guides/self-hosting/): the app points | |
| 19 | 19 | at any server. | |
| 20 | + | - **A runner for your workspace**, started with the app, so your | |
| 21 | + | computer runs the workspace's `runs-on: self-hosted` jobs while g1t is | |
| 22 | + | open, with nothing to install or register. See [the runner](#the-runner). | |
| 20 | 23 | - **Settings of its own**: opening when you sign in to your computer, | |
| 21 | − | what closing the window does, notifications, the badge, zoom and updates. | |
| 24 | + | what closing the window does, notifications, the badge, zoom, updates | |
| 25 | + | and the runner. | |
| 22 | 26 | ||
| 23 | 27 | Get it at [g1t.sh/download](https://g1t.sh/download), or from your account | |
| 24 | 28 | menu, **Get the desktop app**. | |
| ⋯ | |||
| 132 | 136 | | **Show a badge on the icon** | On | The count, or the dot on Windows. Off, the icon stays clean; the tray's hover text still counts. | | |
| 133 | 137 | | **Use the system's title bar** | Off | The system's title bar in place of [g1t's own](#the-title-bar), with the menu bar under it on Windows and Linux. Takes effect when g1t restarts. | | |
| 134 | 138 | | **Zoom** | 100% | How large g1t is drawn, from 67% to 200%. The View menu changes it too. | | |
| 135 | − | | **Download updates by themselves** | On | Off, the app never checks on its own; **Check for updates** still does. | | |
| 139 | + | | **Download updates by themselves** | On | Off, the app never checks on its own; **Check for updates** still does. The runner's updates follow it too. | | |
| 140 | + | | **Run jobs on this computer while g1t is open** | On | Starts [the runner](#the-runner) a few seconds after the window. Off, nothing runs. | | |
| 141 | + | | **How jobs run** | Sandboxed | Sandboxed, in Docker, or directly on this computer. See [how jobs run](#how-jobs-run). | | |
| 142 | + | | **Workspace** | The one you were in when it first registered | The workspace whose jobs the runner runs. Lists the workspaces you own. | | |
| 136 | 143 | ||
| 137 | 144 | The settings window also shows the app's version and whether it is up to | |
| 138 | 145 | date, with **Check for updates** (see [Updates](#updates)), and the server it opens, with **Change…** to [use another | |
| 139 | 146 | g1t](#your-own-g1t). | |
| 140 | 147 | ||
| 148 | + | ## The runner | |
| 149 | + | ||
| 150 | + | The app runs a [self-hosted runner](/guides/self-hosted-runners/) for | |
| 151 | + | you while it is open: your workspace's jobs that ask for `self-hosted` | |
| 152 | + | run on your computer, at no cost, and nothing has to be downloaded, | |
| 153 | + | registered or started by hand. It starts a few seconds after the window | |
| 154 | + | opens, and stops when g1t quits. Time on it is free, as on any of your | |
| 155 | + | runners. | |
| 156 | + | ||
| 157 | + | To stop it, press **Stop** in the settings window's Runner section, or | |
| 158 | + | **Stop running jobs** in the tray; it starts again with **Start**, or the | |
| 159 | + | next time g1t opens. To keep it off, turn off **Run jobs on this computer | |
| 160 | + | while g1t is open** in [the settings](#settings). | |
| 161 | + | ||
| 162 | + | A job still runs only on a runner with every label it names. The app's | |
| 163 | + | runner has `self-hosted`, its OS and architecture (`linux` for a runner | |
| 164 | + | whose jobs run in containers, whatever the computer is), and `desktop`. | |
| 165 | + | It is named after your computer, and a new registration from the same | |
| 166 | + | computer takes the place of the old one. | |
| 167 | + | ||
| 168 | + | ### How jobs run | |
| 169 | + | ||
| 170 | + | **How jobs run** in the settings decides what a job on your computer can | |
| 171 | + | reach. The app never changes it by itself: when the one you chose cannot | |
| 172 | + | start, it says why and runs nothing. | |
| 173 | + | ||
| 174 | + | | Choice | How | [Isolation](/guides/self-hosted-runners/#isolation) | What a job can reach | | |
| 175 | + | | --- | --- | --- | --- | | |
| 176 | + | | **Sandboxed** (the default) | The runner in a sandbox of its own, and each job in a container inside that. On Linux, the runner's image under [Sysbox](https://github.com/nestybox/sysbox), through your Docker, without its socket. On Windows and macOS, a small virtual machine the app makes, once that is released; until then it says *a sandbox isn't available on this computer yet*. | `nested-container` | Its own container. Not your computer. | | |
| 177 | + | | **In Docker** | `g1t-runner` on your computer, driving your Docker. Each job is a container of its own. | `container` | Its own container; but the runner controls your Docker, which can reach your files (Docker Desktop shares your home folder with its VM), so this is not a sandbox. | | |
| 178 | + | | **Directly on this computer** | `g1t-runner` on your computer, each job a process in a folder of its own. For jobs that need the system itself, such as building a Windows installer. | `host` | Everything your account can: your files, your keys, what an earlier job left. Not a sandbox. Never given a public repository's work. | | |
| 179 | + | ||
| 180 | + | On Linux, the sandboxed runner is this container, which the app starts, | |
| 181 | + | replaces and stops itself: | |
| 182 | + | ||
| 183 | + | ```sh | |
| 184 | + | docker run -d --name g1t-desktop-runner --runtime=sysbox-runc \ | |
| 185 | + | -v g1t-desktop-runner:/data \ | |
| 186 | + | -e G1T_RUNNER_URL -e G1T_RUNNER_NAME -e G1T_RUNNER_LABELS=desktop … \ | |
| 187 | + | g1t.sh/flagon-io/g1t-runner:latest register-and-run | |
| 188 | + | ``` | |
| 189 | + | ||
| 190 | + | Its registration is kept on the `g1t-desktop-runner` volume. For the | |
| 191 | + | other two choices the app keeps `g1t-runner` in its data folder | |
| 192 | + | (`%APPDATA%\sh.g1t.desktop\runner` on Windows, | |
| 193 | + | `~/Library/Application Support/sh.g1t.desktop/runner` on macOS, | |
| 194 | + | `~/.local/share/sh.g1t.desktop/runner` on Linux), with each choice's | |
| 195 | + | registration beside it. | |
| 196 | + | ||
| 197 | + | ### Registering | |
| 198 | + | ||
| 199 | + | The first time, and whenever you change the workspace or how jobs run, | |
| 200 | + | the app registers your computer with the workspace. It needs you signed | |
| 201 | + | in to g1t in the app as an owner of the workspace, since only owners add | |
| 202 | + | runners: the page you are on makes a registration token, which lasts an | |
| 203 | + | hour, and hands it to the app, which gives it to the runner once. The | |
| 204 | + | token is never shown, and is not kept. From then on the runner starts | |
| 205 | + | from its registration, signed in or not. | |
| 206 | + | ||
| 207 | + | The workspace is the one you were in when it first registered; choose | |
| 208 | + | another under **Workspace** in the settings. Changing it, or how jobs | |
| 209 | + | run, waits for a job that is running to end, then removes the old | |
| 210 | + | registration and makes a new one. | |
| 211 | + | ||
| 212 | + | ### Keeping it up to date | |
| 213 | + | ||
| 214 | + | The app keeps the runner up to date: soon after it starts and every six | |
| 215 | + | hours, while the runner has no job, it looks for a newer `g1t-runner` in | |
| 216 | + | `g1t.sh/downloads/runner/latest.json` (or a newer image, for the | |
| 217 | + | sandbox), downloads it, checks it against the release's SHA-256, and puts | |
| 218 | + | it in place. A job is never cut short for an update, and one that fails | |
| 219 | + | leaves the runner as it was. Turning off **Download updates by | |
| 220 | + | themselves** turns these off too. | |
| 221 | + | ||
| 222 | + | ### Seeing it | |
| 223 | + | ||
| 224 | + | | Where | What it shows | | |
| 225 | + | | --- | --- | | |
| 226 | + | | The tray | A row with what the runner is doing, *Runner: Waiting for jobs*, which opens the settings; **Stop running jobs** or **Start running jobs** under it. The icon's hover text says it too. | | |
| 227 | + | | The settings window | What it is doing; when it isn't running, why and how to fix it, with the fix's buttons; how jobs run; the workspace; your computer's name and the runner's version; **View on g1t**; and the runner's recent log, with **Open the whole log**. | | |
| 228 | + | | The workspace's [Runners page](/guides/self-hosted-runners/#the-runners-page) | **Desktops**: every computer with the app in the workspace, with what each is doing, or why it isn't running and the fix, in the same words. Inside the app, this computer comes first, live, with its buttons. A computer whose g1t is closed says *g1t is closed on* its name. | | |
| 229 | + | ||
| 230 | + | ### When it isn't running | |
| 231 | + | ||
| 232 | + | Each state says why, and how to fix it, in the same words in the tray, | |
| 233 | + | the settings and the Runners page. | |
| 234 | + | ||
| 235 | + | | It says | Why | What to do | | |
| 236 | + | | --- | --- | --- | | |
| 237 | + | | *Waiting for jobs* | It is registered and listening. | Nothing. | | |
| 238 | + | | *Running* a job | It is running that job. | Nothing; the Runners page links to its run. | | |
| 239 | + | | *Starting*, *Registering with* a workspace, *Updating* | It is on its way. | Wait a few seconds. | | |
| 240 | + | | *Not running: turned off* | **Run jobs on this computer while g1t is open** is off. | **Turn on**. | | |
| 241 | + | | *Not running: stopped* | You pressed **Stop**. | **Start**, or open g1t again. | | |
| 242 | + | | *Not running: signed out* | It isn't registered yet, and registering needs you signed in. | **Sign in**, as an owner of the workspace. | | |
| 243 | + | | *Not running: no workspace to run jobs for* | You don't own a workspace, and only owners add runners. | Ask an owner, or **Create a workspace**. | | |
| 244 | + | | *Couldn't register with* a workspace: *only its owners can* | You aren't an owner of it. | Ask an owner to make you one, or **Choose workspace**. | | |
| 245 | + | | *Couldn't register with* a workspace, with g1t's reason | g1t refused the registration, such as for too many tokens in an hour. | **Retry**; it also tries again by itself. | | |
| 246 | + | | *Not running: Docker isn't running* | Running jobs in Docker, or the sandbox on Linux, needs Docker, and it isn't running or isn't installed. | **Open Docker** (Docker Desktop), or **Get Docker**. It starts by itself once Docker answers. | | |
| 247 | + | | *Not running: a sandbox isn't available on this computer yet* | The sandbox for Windows and macOS isn't released. | Wait for it, or **Choose how jobs run**: in Docker or directly on this computer, neither of which is a sandbox. | | |
| 248 | + | | *Not running: Sysbox isn't installed* | The sandbox on Linux needs Sysbox in your Docker. | **Install Sysbox**. | | |
| 249 | + | | *Offline:* the server *unreachable* | Your computer can't reach g1t. | Check the connection; it carries on by itself. **Retry** tries now. | | |
| 250 | + | | *Update failed* | A new version couldn't be downloaded, or its SHA-256 wasn't the release's. | It keeps running the version it has. **Retry**, or **Report** it. | | |
| 251 | + | | *Runner stopped (exit code* N*)* | The runner ended by itself, with the reason from its log. | It starts again by itself, waiting longer each time. **Restart** now, or **View log**. | | |
| 252 | + | | *g1t is closed on* a computer | Seen on the Runners page: g1t hasn't been open on that computer for ten minutes. | Open g1t there. | | |
| 253 | + | ||
| 141 | 254 | ## Links | |
| 142 | 255 | ||
| 143 | 256 | A **`g1t://` link** opens a page in the app: `g1t://acme/-/chat/dm/chn_1` | |
| ⋯ | |||
| 194 | 307 | ||
| 195 | 308 | ## What's not there yet | |
| 196 | 309 | ||
| 197 | − | - **A runner inside the app**, so an agent can work on your own machine | |
| 198 | − | with what only it reaches, as [the runners page](/guides/self-hosted-runners/) | |
| 199 | − | says. The self-hosted runner works on the same machine meanwhile. | |
| 310 | + | - **The sandbox on Windows and macOS.** Until it is released, the runner | |
| 311 | + | there runs jobs only if you choose Docker or this computer itself; see | |
| 312 | + | [how jobs run](#how-jobs-run). | |
| 313 | + | - **Agents on your desktop.** The runner takes workflow jobs, and agent | |
| 314 | + | work only if the workspace [sends it to its runners](/guides/self-hosted-runners/#agents-on-your-runners). | |
| 200 | 315 | - **Opening the conversation from a notification.** Clicking one brings | |
| 201 | 316 | the app forward; the page it is about is coming. | |
| 202 | 317 | - **A page of its own while offline.** If the network goes away while a | |
| 96 | 96 | ||
| 97 | 97 | | Isolation | How you get it | What a job can reach | | |
| 98 | 98 | | --- | --- | --- | | |
| 99 | − | | `nested-container` | The runner's image under Sysbox, as above. | Its own container, and its own Docker Engine if it uses Docker. Not the machine, and not other jobs. | | |
| 100 | − | | `container` | `g1t-runner` installed on the machine with Docker, or the image given the machine's Docker socket with the override. | Its own container. The runner itself drives the machine's Docker, which can do anything on the machine, so a job that escapes its container, or anything that takes over the runner, controls the machine. | | |
| 101 | − | | `host` | `--no-docker`. Deprecated. | Everything the runner's user can: the machine, its files, and what earlier jobs left. | | |
| 99 | + | | `nested-container` | The runner's image under Sysbox, as above, or [the desktop app](/guides/desktop/#how-jobs-run)'s Sandboxed choice. | Its own container, and its own Docker Engine if it uses Docker. Not the machine, and not other jobs. | | |
| 100 | + | | `container` | `g1t-runner` installed on the machine with Docker, the desktop app's In Docker choice, or the image given the machine's Docker socket with the override. | Its own container. The runner itself drives the machine's Docker, which can do anything on the machine, so a job that escapes its container, or anything that takes over the runner, controls the machine. | | |
| 101 | + | | `host` | `--no-docker`, or the desktop app's Directly on this computer choice. Deprecated. | Everything the runner's user can: the machine, its files, and what earlier jobs left. | | |
| 102 | 102 | ||
| 103 | 103 | ### What a job's container gets | |
| 104 | 104 | ||
| ⋯ | |||
| 201 | 201 | [a computer of its own](/guides/agents/#its-computer) on g1t cloud, whose | |
| 202 | 202 | home stays between sessions and which sleeps when idle; the page says so | |
| 203 | 203 | under **Agents' computers and more**. Pinning an agent's computer to one of | |
| 204 | − | your own runners, a runner in the desktop app, and an official agent image | |
| 205 | − | are coming, and the page labels them so. | |
| 204 | + | your own runners and an official agent image are coming, and the page | |
| 205 | + | labels them so. | |
| 206 | + | ||
| 207 | + | **Desktops** lists every computer in the workspace with [the desktop | |
| 208 | + | app](/guides/desktop/#the-runner), whose runner runs while g1t is open | |
| 209 | + | there: what each is doing, or why it isn't running and how to fix it, in | |
| 210 | + | the app's own words, and how it runs jobs. A computer whose g1t has been | |
| 211 | + | closed for ten minutes says *g1t is closed on* its name, and so does its | |
| 212 | + | runner in the list, rather than *Offline*. | |
| 213 | + | ||
| 214 | + | ## A runner in the desktop app | |
| 215 | + | ||
| 216 | + | [The desktop app](/guides/desktop/#the-runner) runs a runner for you | |
| 217 | + | while it is open, with nothing to install or register: it registers your | |
| 218 | + | computer with the workspace you choose, as you, through the site, and | |
| 219 | + | keeps the runner up to date. It runs jobs sandboxed by default | |
| 220 | + | (`nested-container`); running them in Docker (`container`) or directly | |
| 221 | + | on the computer (`host`) are settings you choose, and the app never falls | |
| 222 | + | back to them. Its runner has the label `desktop` besides the usual ones. | |
| 206 | 223 | ||
| 207 | 224 | ## Add a runner | |
| 208 | 225 | ||
| 70 | 70 | | Scratchpads | Tools, reports and agents anyone can build on a copy of the data, used in production only after review. | <Status is="coming" /> | | |
| 71 | 71 | | [The desktop app](/guides/desktop/) | g1t in a window of its own for Windows, macOS and Linux, with notifications from your system. | <Status is="preview" /> | | |
| 72 | 72 | | [The phone app](/guides/mobile/) | Chat with your team and its agents, live, with notifications on the phone; review pull requests. Android first, for g1t.sh or your own g1t; not in the stores yet. | <Status is="preview" /> | | |
| 73 | − | | A runner inside the desktop app | Agents working on your own machine, with what only it reaches. | <Status is="coming" /> | | |
| 73 | + | | [A runner in the desktop app](/guides/desktop/#the-runner) | Your workspace's self-hosted jobs on your computer while g1t is open, registered and kept up to date by the app, sandboxed by default. The sandbox on Windows and macOS is coming; until then jobs there run in Docker or on the computer, if you choose. | <Status is="preview" /> | | |
| 74 | 74 | ||
| 75 | 75 | ## Agents | |
| 76 | 76 |
| 1 | + | import { useEffect, useRef } from "react"; | |
| 2 | + | ||
| 3 | + | import type { DesktopReport } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import { 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 | + | */ | |
| 13 | + | export 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 | + | } |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…