Skip to content

Commit

Merge branch 'main' into rm-agents

syntaqxcommitted Parents371e0ddd0c3abaBrowse files
57 files+481−14441 files not counted0/57 viewed
+74−1
911911 ]
912912
913913 [[package]]
914+name = "deranged"
915+version = "0.5.8"
916+source = "registry+https://github.com/rust-lang/crates.io-index"
917+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
918+
919+[[package]]
914920 name = "des"
915921 version = "0.9.0"
916922 source = "registry+https://github.com/rust-lang/crates.io-index"
15221528
15231529 [[package]]
15241530 name = "g1t-runner"
1525−version = "0.3.2"
1531+version = "0.3.3"
15261532 dependencies = [
15271533 "anyhow",
15281534 "base64 0.22.1",
15331539 "g1t-scan",
15341540 "hex",
15351541 "libc",
1542+ "rcgen",
15361543 "regex",
1544+ "rustls",
15371545 "serde",
15381546 "serde_json",
15391547 "serde_yaml",
15401548 "sha2 0.10.9",
1549+ "time",
15411550 "toml",
15421551 "ureq",
1552+ "webpki-roots 0.26.11",
15431553 ]
15441554
15451555 [[package]]
25312541 ]
25322542
25332543 [[package]]
2544+name = "num-conv"
2545+version = "0.2.2"
2546+source = "registry+https://github.com/rust-lang/crates.io-index"
2547+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
2548+
2549+[[package]]
25342550 name = "num-integer"
25352551 version = "0.1.47"
25362552 source = "registry+https://github.com/rust-lang/crates.io-index"
27292745 ]
27302746
27312747 [[package]]
2748+name = "pem"
2749+version = "3.0.6"
2750+source = "registry+https://github.com/rust-lang/crates.io-index"
2751+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
2752+dependencies = [
2753+ "base64 0.22.1",
2754+ "serde_core",
2755+]
2756+
2757+[[package]]
27322758 name = "pem-rfc7468"
27332759 version = "1.0.0"
27342760 source = "registry+https://github.com/rust-lang/crates.io-index"
29092935 ]
29102936
29112937 [[package]]
2938+name = "powerfmt"
2939+version = "0.2.1"
2940+source = "registry+https://github.com/rust-lang/crates.io-index"
2941+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
2942+
2943+[[package]]
29122944 name = "primefield"
29132945 version = "0.14.0"
29142946 source = "registry+https://github.com/rust-lang/crates.io-index"
30613093 ]
30623094
30633095 [[package]]
3096+name = "rcgen"
3097+version = "0.13.2"
3098+source = "registry+https://github.com/rust-lang/crates.io-index"
3099+checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
3100+dependencies = [
3101+ "pem",
3102+ "ring",
3103+ "rustls-pki-types",
3104+ "time",
3105+ "yasna",
3106+]
3107+
3108+[[package]]
30643109 name = "redox_syscall"
30653110 version = "0.5.18"
30663111 source = "registry+https://github.com/rust-lang/crates.io-index"
40684113 ]
40694114
40704115 [[package]]
4116+name = "time"
4117+version = "0.3.55"
4118+source = "registry+https://github.com/rust-lang/crates.io-index"
4119+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
4120+dependencies = [
4121+ "deranged",
4122+ "num-conv",
4123+ "powerfmt",
4124+ "serde_core",
4125+ "time-core",
4126+]
4127+
4128+[[package]]
4129+name = "time-core"
4130+version = "0.1.9"
4131+source = "registry+https://github.com/rust-lang/crates.io-index"
4132+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
4133+
4134+[[package]]
40714135 name = "tinystr"
40724136 version = "0.8.4"
40734137 source = "registry+https://github.com/rust-lang/crates.io-index"
49405004 ]
49415005
49425006 [[package]]
5007+name = "yasna"
5008+version = "0.5.2"
5009+source = "registry+https://github.com/rust-lang/crates.io-index"
5010+checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
5011+dependencies = [
5012+ "time",
5013+]
5014+
5015+[[package]]
49435016 name = "yoke"
49445017 version = "0.8.3"
49455018 source = "registry+https://github.com/rust-lang/crates.io-index"
+57−0
1+//! `POST /actions/jobs/{job}/broker`: a request a self-hosted runner's job
2+//! made to one of its brokered secrets' hosts, which the job's harness
3+//! passes to g1t so the secret's value never reaches the runner's
4+//! computer (services/actions broker.rs, crates/runner actions/broker.rs).
5+//!
6+//! The job's current token comes in `Authorization: Bearer`, never in the
7+//! URL, and the request to make in `x-g1t-broker-request` (base64 of
8+//! `{ method, url, headers }`), with its body as this request's. The
9+//! runner Worker does the rest (services/runner broker-relay.ts): it
10+//! fills in the stand-ins, sends the request without following redirects,
11+//! and answers with what came back, scrubbed of the values, its status and
12+//! headers in `x-g1t-broker-response`. Bodies stream through here as they
13+//! are; the Worker holds the limits.
14+
15+use worker::wasm_bindgen::JsValue;
16+use worker::{Method, Request, RequestInit, Response, Result};
17+
18+use crate::operations::Services;
19+
20+/// The headers passed on to the runner Worker; nothing else of the
21+/// request is. Its body's length the Worker counts as it reads.
22+const PASSED: &[&str] = &["authorization", "x-g1t-broker-request"];
23+
24+/// The job a broker request is for, from its path, or None when the path
25+/// is not one.
26+pub fn job_of(path: &str) -> Option<&str> {
27+ let job = path.strip_prefix("/actions/jobs/")?.strip_suffix("/broker")?;
28+ (!job.is_empty() && !job.contains('/')).then_some(job)
29+}
30+
31+/// Passes a broker request to the runner Worker, and its answer back.
32+pub async fn relay(request: Request, services: &Services, job: &str) -> Result<Response> {
33+ let headers = worker::Headers::new();
34+ headers.set("x-g1t-broker-job", job)?;
35+ for name in PASSED {
36+ if let Some(value) = request.headers().get(name)? {
37+ headers.set(name, &value)?;
38+ }
39+ }
40+ let mut init = RequestInit::new();
41+ init.with_method(Method::Post).with_headers(headers).with_body(request.inner().body().map(JsValue::from));
42+ services.runner.fetch_request(Request::new_with_init("https://runner/rpc/broker", &init)?).await
43+}
44+
45+#[cfg(test)]
46+mod tests {
47+ use super::job_of;
48+
49+ #[test]
50+ fn only_a_job_s_own_broker_path_is_one() {
51+ assert_eq!(job_of("/actions/jobs/job_1/broker"), Some("job_1"));
52+ assert_eq!(job_of("/actions/jobs//broker"), None);
53+ assert_eq!(job_of("/actions/jobs/job_1/spec"), None);
54+ assert_eq!(job_of("/actions/jobs/a/b/broker"), None);
55+ assert_eq!(job_of("/repos/a/b/actions/jobs/job_1/broker"), None);
56+ }
57+}
+14−1
1212 mod audit;
1313 mod billing;
1414 mod blobs;
15+mod broker;
1516 mod checks;
1617 mod deployments;
1718 mod telemetry;
527528 let rest = rest.to_owned();
528529 return blobs::for_job(request, env, &services, method, &rest).await;
529530 }
531+ // A self-hosted job's request to a brokered secret's host, passed to
532+ // g1t to be filled in (broker.rs). The job's token is the credential.
533+ if !on_mcp
534+ && method == "POST"
535+ && let Some(job) = broker::job_of(&path)
536+ {
537+ let job = job.to_owned();
538+ return broker::relay(request, &services, &job).await;
539+ }
530540
531541 // A self-hosted runner, with a registration token or its own
532542 // credential, neither of which is a g1t access token.
667677 // `rotate`: a harness that takes a new token with the spec and
668678 // lets go of the one it asked with (plan.rs `job_spec`).
669679 let rotate = method == "job_spec" && body["rotate"].as_bool() == Some(true);
680+ // `broker`: a self-hosted harness that passes its job's
681+ // requests to brokered hosts through g1t (broker.rs).
682+ let broker = method == "job_spec" && body["broker"].as_bool() == Some(true);
670683 let answered: Outcome<Value> = g1t_kit::call(
671684 &services.actions,
672685 method,
673− &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate }),
686+ &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate, "broker": broker }),
674687 )
675688 .await?;
676689 return match answered {
+1−1
15251525 "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, whether workflows (`secrets.NAME`), deployments, or both read it, and for a brokered secret the hosts g1t fills it in for (`brokered_hosts`). Values are never returned. A repository's need the Admin role on it; a workspace's, a member."
15261526 }
15271527 Op::SetActionsSecret => {
1528− "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. `brokered_hosts` brokers the secret: workflow jobs on g1t's machines get a stand-in in place of its value, and g1t puts the real value on the job's requests to those hosts (in headers, Basic auth included, and the query string) as they leave its sandbox, so the value never enters it. A brokered secret is read by workflows only and its value is at least 8 characters; an empty list stops brokering it. A repository's need the Admin role on it; a workspace's, an owner. Workspace tokens, G1T_TOKEN included, cannot change them."
1528+ "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. `brokered_hosts` brokers the secret: workflow jobs on g1t's machines get a stand-in in place of its value, and g1t puts the real value on the job's requests to those hosts (in headers, Basic auth included, and the query string) as they leave its sandbox, so the value never enters it; a self-hosted runner whose jobs run on Linux or macOS passes the job's requests to those hosts through g1t, so the value never reaches its computer either. A brokered secret is read by workflows only and its value is at least 8 characters; an empty list stops brokering it. A repository's need the Admin role on it; a workspace's, an owner. Workspace tokens, G1T_TOKEN included, cannot change them."
15291529 }
15301530 Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.",
15311531 Op::ListActionsVariables => {
+82−0
1+// A button at work, in the app's own pages (settings.js, runner.js): from
2+// the click until what it asked for shows, a spinner before its label
3+// (settings.css `button.busy`), pressed no more, and said to be busy to a
4+// screen reader. What it asked for shows when the status that follows
5+// changes; an error, or 20 seconds with no change, gives the button back,
6+// so a press is never seen to do nothing and never leaves a button dead.
7+//
8+// A page that draws its buttons again on every status asks `pressed` while
9+// drawing: a button pressed with the status as it still is stays busy.
10+(function () {
11+ var LIMIT = 20000;
12+ var timers = new WeakMap();
13+ // What was pressed (by a key such as "retry" or "start:linux"), with the
14+ // status as it was then and when.
15+ var presses = {};
16+
17+ function start(button) {
18+ if (!button || button.classList.contains("busy")) return;
19+ button.disabled = true;
20+ button.classList.add("busy");
21+ button.setAttribute("aria-busy", "true");
22+ timers.set(
23+ button,
24+ setTimeout(function () {
25+ stop(button);
26+ }, LIMIT),
27+ );
28+ }
29+
30+ function stop(button) {
31+ if (!button || !button.classList.contains("busy")) return;
32+ clearTimeout(timers.get(button));
33+ timers.delete(button);
34+ button.disabled = false;
35+ button.classList.remove("busy");
36+ button.removeAttribute("aria-busy");
37+ }
38+
39+ function press(key, status) {
40+ presses[key] = { status: status, at: Date.now() };
41+ }
42+
43+ function release(key) {
44+ delete presses[key];
45+ }
46+
47+ // Whether a press is still waiting: the status is as it was, and not for long.
48+ function pressed(key, status) {
49+ var press = presses[key];
50+ if (!press) return false;
51+ if (press.status !== status || Date.now() - press.at >= LIMIT) {
52+ delete presses[key];
53+ return false;
54+ }
55+ return true;
56+ }
57+
58+ // Runs `work` (a function returning a promise) for a button: busy until
59+ // the status changes when `waits`, or until the work is done. Resolves
60+ // whether the work went through; `fail` is told why it did not.
61+ function run(button, key, status, waits, work, fail) {
62+ press(key, status);
63+ start(button);
64+ return work().then(
65+ function () {
66+ if (!waits) {
67+ release(key);
68+ stop(button);
69+ }
70+ return true;
71+ },
72+ function (error) {
73+ release(key);
74+ stop(button);
75+ if (fail) fail(error);
76+ return false;
77+ },
78+ );
79+ }
80+
81+ window.g1tBusy = Object.freeze({ LIMIT: LIMIT, start: start, stop: stop, press: press, release: release, pressed: pressed, run: run });
82+})();
+5−2
11 <!doctype html>
22 <!--
33 The runner, in a window of its own, apart from the settings: opened from
4− the chip beside the title bar's settings button, the tray, or the menu
5− (src/settings.rs open_runner). What it is doing, each kind of job it
4+ the dot beside the title bar's settings button, or the tray, while the
5+ window shows no page of the site's to draw the runner menu, and where
6+ jobs are taken out of the sandbox, which only a page bundled with the
7+ app may do (src/settings.rs runner_menu). What it is doing, each kind of job it
68 runs and the job each is running, how jobs run and for which workspace,
79 its updates and its log, live (runner.js). Its words are the app's
810 (src/runner/diagnosis.rs).
101103 <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/#the-runner">About the runner</a></p>
102104 </main>
103105 </div>
106+ <script src="busy.js"></script>
104107 <script src="runner.js"></script>
105108 </body>
106109 </html>
+68−89
4444 return item;
4545 }
4646
47− // A fix's button. Choosing is done here, in this window; a copy says it was copied.
48− function actionButton(action, primary) {
47+ var busy = window.g1tBusy;
48+
49+ // The whole runner's status, as far as a button waits on it: a press is
50+ // done when any of this changes (busy.js).
51+ function runnerStatus(runner) {
52+ var d = runner.diagnosis;
53+ var update = d.update || {};
54+ return JSON.stringify([d.code, d.tone, d.title, d.cause, runner.chip.label, runner.enabled, runner.paused, update.checking, update.checked_at, update.ready]);
55+ }
56+
57+ // One kind of job's, with the whole runner's.
58+ function kindStatus(kind, runner) {
59+ return JSON.stringify([runnerStatus(runner), kind.state, kind.enabled, kind.tone, kind.title, kind.cause]);
60+ }
61+
62+ // A fix's button. Choosing is done here, in this window; a copy says it
63+ // was copied. One that changes the runner (the catalogue says which:
64+ // `waits`) stays busy until the status that follows; one the catalogue
65+ // asks about first (`confirm`) asks.
66+ function actionButton(action, primary, key, status) {
4967 var button = document.createElement("button");
5068 button.type = "button";
5169 button.textContent = action.label;
5270 if (primary) button.className = "primary";
71+ key = key + ":" + action.id;
72+ if (busy.pressed(key, status)) busy.start(button);
5373 button.addEventListener("click", function () {
5474 if (action.id === "choose_workspace") return byId("runnerWorkspace").focus();
5575 if (action.id === "choose_backend") return byId("runnerBackend").focus();
56− if (action.id === "remove_sandbox" && !window.confirm(REMOVE_SANDBOX)) return;
57− var copying = action.id.indexOf("copy_") === 0;
58− // Busy until the status that follows draws the buttons again, so a
59− // press is seen to do something; given back if nothing comes.
60− if (!copying) working(button);
61− invoke("runner_control", { action: action.id }).then(
62− function () {
63− if (!copying) return;
64− button.textContent = "Copied";
65− setTimeout(function () {
66− button.textContent = action.label;
67− }, 2000);
68− },
69− function (error) {
70− done(button, action.label);
71− fail(error);
72− },
73− );
76+ if (action.confirm && !window.confirm(action.confirm)) return;
77+ busy.run(button, key, status, !!action.waits, function () {
78+ return invoke("runner_control", { action: action.id });
79+ }, fail).then(function (done) {
80+ if (!done || action.id.indexOf("copy_") !== 0) return;
81+ button.textContent = "Copied";
82+ setTimeout(function () {
83+ button.textContent = action.label;
84+ }, 2000);
85+ });
7486 });
7587 return button;
7688 }
77−
78− var REMOVE_SANDBOX =
79− "Remove this computer's sandbox? Its VM and registration go, and g1t makes its own in their place, registered with your workspace. Its old runners stay listed on g1t until you remove them there.";
8089
81− // A button at work: a spinner and its label, pressed no more until it is done.
82− function working(button) {
83− button.disabled = true;
84− button.classList.add("busy");
85− button.setAttribute("aria-busy", "true");
86− var label = button.textContent;
87− setTimeout(function () {
88− if (button.isConnected) done(button, label);
89− }, 20000);
90− }
91−
92− function done(button, label) {
93− button.disabled = false;
94− button.classList.remove("busy");
95− button.removeAttribute("aria-busy");
96− button.textContent = label;
97− }
98−
9990 function element(tag, className, text) {
10091 var item = document.createElement(tag);
10192 if (className) item.className = className;
133124 var ISOLATION = { "nested-container": "A fresh container in the VM", vm: "A VM of its own", container: "A container of its own", host: "None" };
134125 var BUSY = { busy: true, starting: true, downloading: true, updating: true };
135126 var cards = {};
136− var pending = {};
137127
138128 // A small arrow: the link goes out to g1t.
139129 function outward() {
249239 list.appendChild(row);
250240 }
251241
252− // Start or Stop for one kind of job, said for which, and what it is doing meanwhile.
253− function toggleButton(kind, onFirst) {
242+ // Start or Stop for one kind of job, said for which: busy from the click
243+ // until the status that follows says it started or stopped.
244+ function toggleButton(kind, onFirst, runner) {
254245 var on = kind.enabled;
255− var waiting = pending[kind.id];
256246 var button = element("button", on ? "quiet" : onFirst ? "primary" : null);
257247 button.type = "button";
258− button.textContent = waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start";
248+ button.textContent = on ? "Stop" : "Start";
259249 button.setAttribute("aria-label", (on ? "Stop " : "Start ") + kind.label);
260− button.disabled = !!waiting;
250+ var key = "toggle:" + kind.id;
251+ var status = kindStatus(kind, runner);
252+ if (busy.pressed(key, status)) busy.start(button);
261253 button.addEventListener("click", function () {
262− pending[kind.id] = true;
263− renderKinds(lastKinds);
264− invoke("runner_control", { action: on ? "stop" : "start", capability: kind.id })
265− .catch(fail)
266− .then(function () {
267− // The status that follows says how it is; until then it says what was asked.
268− setTimeout(function () {
269− delete pending[kind.id];
270− renderKinds(lastKinds);
271− }, 1500);
272− });
254+ busy.run(button, key, status, true, function () {
255+ return invoke("runner_control", { action: on ? "stop" : "start", capability: kind.id });
256+ }, fail);
273257 });
274258 return button;
275259 }
289273 // What changes the buttons and the words is drawn again only when it changes,
290274 // so a button keeps its focus while the status ticks.
291275 var update = runner.diagnosis.update || null;
292− var signature = JSON.stringify([kind, !!pending[kind.id], runner.link, update && update.checked]);
276+ var signature = JSON.stringify([kind, runner.link, update && update.checked]);
293277 if (signature === card.signature) return;
294278 card.signature = signature;
295279
333317 // sees to it (waiting for the release's images), none does.
334318 var urgent = kind.tone === "problem" || kind.tone === "warning";
335319 kind.actions.forEach(function (action, at) {
336− actions.appendChild(actionButton(action, at === 0 && urgent && kind.state !== "waiting"));
320+ actions.appendChild(actionButton(action, at === 0 && urgent && kind.state !== "waiting", kind.id, kindStatus(kind, runner)));
337321 });
338322 if (kind.can_toggle) {
339− var toggle = toggleButton(kind, kind.actions.length === 0);
323+ var toggle = toggleButton(kind, kind.actions.length === 0, runner);
340324 toggle.classList.add("end");
341325 actions.appendChild(toggle);
342326 }
343327 actions.hidden = !actions.firstChild;
344328 }
345329
346− var lastKinds = null;
347−
348330 function renderKinds(runner) {
349− lastKinds = runner;
350331 var list = byId("runner-kinds");
351332 var kinds = (runner && runner.diagnosis.capabilities) || [];
352333 list.hidden = kinds.length === 0;
379360 var actions = byId("runner-actions");
380361 actions.textContent = "";
381362 diagnosis.actions.forEach(function (action, at) {
382− actions.appendChild(actionButton(action, at === 0));
363+ actions.appendChild(actionButton(action, at === 0, "runner", runnerStatus(runner)));
383364 });
384365 renderKinds(runner);
385366 byId("runner").checked = !!runner.enabled;
414395 check.hidden = !update;
415396 if (update) {
416397 check.textContent = update.check.label;
417− check.disabled = update.checking;
398+ // Busy while the app looks, whoever asked, and from a click until it says it is looking.
399+ if (update.checking || busy.pressed("check", runnerStatus(runner))) busy.start(check);
400+ else busy.stop(check);
418401 }
402+ // A Start or Stop pressed is done once the status says so.
403+ if (!busy.pressed("engine", runnerStatus(runner))) busy.stop(byId("engine-toggle"));
419404 byId("runner-view").hidden = !runner.link;
420405 // The log follows along, unless it was scrolled back to read.
421406 var log = byId("runner-log");
426411
427412 byId("runnerBackend").addEventListener("change", save);
428413 byId("runnerWorkspace").addEventListener("change", save);
429− byId("runner-view").addEventListener("click", function () {
430− invoke("runner_control", { action: "view_runners" }).catch(fail);
431− });
432− byId("runner-open-log").addEventListener("click", function () {
433− invoke("runner_control", { action: "view_log" }).catch(fail);
434− });
435− // Looks now; what it finds installs as each kind of job is idle, and the status that follows says so.
436− byId("runner-check").addEventListener("click", function () {
437− byId("runner-check").disabled = true;
438− invoke("runner_control", { action: "check_updates" }).catch(function (error) {
439− byId("runner-check").disabled = false;
440− fail(error);
414+ // A button that asks the runner for something: busy until the status
415+ // that follows when it `waits`, or until the app has it.
416+ function control(id, key, action, waits) {
417+ var button = byId(id);
418+ button.addEventListener("click", function () {
419+ var status = current && current.runnerView ? runnerStatus(current.runnerView) : "";
420+ busy.run(button, key, status, waits, function () {
421+ return invoke("runner_control", { action: typeof action === "function" ? action() : action });
422+ }, fail);
441423 });
442− });
424+ }
425+ control("runner-view", "view", "view_runners", false);
426+ control("runner-open-log", "log", "view_log", false);
427+ // Looks now; what it finds installs as each kind of job is idle, and the status that follows says so.
428+ control("runner-check", "check", "check_updates", true);
443429
444430 // ── The whole runner, at the top ─────────────────────────────────────
445431 // As the title bar's chip says it, with what it is doing in a line and
458444 toggle.dataset.action = on ? "stop" : "start";
459445 }
460446
461− byId("engine-toggle").addEventListener("click", function () {
462− var toggle = byId("engine-toggle");
463− toggle.disabled = true;
464− invoke("runner_control", { action: toggle.dataset.action })
465− .catch(fail)
466− .then(function () {
467− toggle.disabled = false;
468− });
469− });
447+ control("engine-toggle", "engine", function () {
448+ return byId("engine-toggle").dataset.action;
449+ }, true);
470450
471451 // Every setting, as the app has it now (the settings window changes the
472452 // rest), with the runner's own as they are on the page.
501481 events
502482 .listen("g1t-runner-status", function (event) {
503483 if (current) current.runnerView = event.payload;
504− pending = {};
505484 renderRunner(event.payload);
506485 })
507486 .catch(function () {});
+1−0
129129 <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p>
130130 </main>
131131 </div>
132+ <script src="busy.js"></script>
132133 <script src="settings.js"></script>
133134 </body>
134135 </html>
+17−10
11 // The settings window's work: show what the app has, and keep each change
22 // the moment it is made. Everything it asks is a command in
3−// src/bridge.rs, answered only to this window. The runner has a window of
4−// its own (runner.html); here it is a line that opens it.
3+// src/bridge.rs. The settings usually open over the site, which draws
4+// them (apps/web components/desktop-settings.tsx); this window is for when
5+// the window shows no page of the site's. The runner has a window of its
6+// own (runner.html); here it is a line that opens it. A button that asks
7+// the app for something is busy until it is done (busy.js).
58 (function () {
69 var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"];
710 // The settings the app keeps (src/settings.rs Settings); a save sends them all.
98101 }
99102
100103 byId("open-runner").addEventListener("click", function () {
101− invoke("runner_control", { action: "open_runner" }).catch(fail);
104+ window.g1tBusy.run(byId("open-runner"), "open-runner", "", false, function () {
105+ return invoke("runner_control", { action: "open_runner" });
106+ }, fail);
102107 });
103108
104109 SWITCHES.forEach(function (key) {
143148 checking = true;
144149 var button = byId("check");
145150 var status = byId("update-status");
146− button.disabled = true;
151+ window.g1tBusy.start(button);
147152 status.textContent = "Checking, and downloading any new version…";
148153 invoke("check_for_updates")
149154 .then(function (answer) {
158163 })
159164 .then(function () {
160165 checking = false;
161− button.disabled = false;
166+ window.g1tBusy.stop(button);
162167 });
163168 }
164169
165170 byId("check").addEventListener("click", check);
166171 byId("restart").addEventListener("click", function () {
167− byId("restart").disabled = true;
172+ window.g1tBusy.start(byId("restart"));
168173 byId("update-status").textContent = "Installing…";
169174 invoke("restart_to_update").catch(function (error) {
170− byId("restart").disabled = false;
175+ window.g1tBusy.stop(byId("restart"));
171176 fail(error);
172177 });
173178 });
191196 }
192197
193198 byId("restart-app").addEventListener("click", function () {
194− byId("restart-app").disabled = true;
199+ window.g1tBusy.start(byId("restart-app"));
195200 invoke("restart").catch(function (error) {
196− byId("restart-app").disabled = false;
201+ window.g1tBusy.stop(byId("restart-app"));
197202 fail(error);
198203 });
199204 });
200205
201206 byId("change-server").addEventListener("click", function () {
202− invoke("change_server").catch(fail);
207+ window.g1tBusy.run(byId("change-server"), "change-server", "", false, function () {
208+ return invoke("change_server");
209+ }, fail);
203210 });
204211
205212 // Links out, in the system's browser (src/bridge.js does the same on the site).
+19−11
113113 outline: 2px solid var(--accent);
114114 outline-offset: -2px;
115115 }
116−/* The runner's chip: a dot in its tone and what it is doing. */
116+/* The runner: a dot in its tone, right beside the settings, the size of
117+ an icon button. A wide window has room for what it is doing too. Its
118+ menu hangs under it, drawn by the page below (the site). */
117119 .chip {
118− gap: 6px;
119− height: 24px;
120− margin-right: 4px;
121− padding: 0 9px;
120+ gap: 7px;
121+ min-width: 28px;
122+ height: 26px;
123+ padding: 0 10px;
122124 border-radius: 6px;
123125 white-space: nowrap;
124126 }
125127 .chip[hidden] {
126128 display: none;
127129 }
128−.chip:hover {
130+.chip:hover,
131+.chip[aria-expanded="true"] {
129132 background: var(--hover);
130133 color: var(--fg);
131134 }
132135 .chip-dot {
133− width: 7px;
134− height: 7px;
136+ width: 8px;
137+ height: 8px;
138+ flex: none;
135139 border-radius: 50%;
136140 background: var(--muted);
137141 }
162166 animation: none;
163167 }
164168 }
165−/* A narrow window keeps the dot alone. */
166−@media (max-width: 760px) {
169+/* The dot alone, until the window is wide enough that the words fit
170+ beside it without crowding the search. */
171+.chip-text {
172+ display: none;
173+}
174+@media (min-width: 1360px) {
167175 .chip-text {
168− display: none;
176+ display: inline;
169177 }
170178 }
+16−7
7979 });
8080 left.append(back, forward);
8181
82− // The runner, in a few words beside the settings, as Docker Desktop shows
83− // its engine: what it is doing, live, and a click opens its window
84− // (runner.html). The words are the app's (diagnosis.rs chip).
85− var chip = button("chip", "Runner", null, function () {
86− act("runner");
82+ // The runner, as a dot right beside the settings: its colour says what
83+ // it is doing, live, and a wide window says it in words too. A click
84+ // opens its menu, which the site draws under the dot (or, with no page
85+ // of the site's to draw it, the Runner window). The words are the app's
86+ // (diagnosis.rs chip).
87+ var chip = button("chip", "Runner", null, function (b) {
88+ var box = b.getBoundingClientRect();
89+ var width = document.documentElement.clientWidth || window.innerWidth || 1;
90+ // Where the dot's right edge is, as a share of the bar, so the menu
91+ // lines up under it whatever the site's zoom.
92+ quietly(invoke("title_bar_act", { action: "runner", at: box.right / width }));
8793 });
8894 chip.hidden = true;
95+ chip.setAttribute("aria-haspopup", "dialog");
96+ chip.setAttribute("aria-expanded", "false");
8997 var chipDot = document.createElement("span");
9098 chipDot.className = "chip-dot";
9199 var chipText = document.createElement("span");
96104 chip.hidden = false;
97105 chip.dataset.tone = said.tone;
98106 chipText.textContent = said.label;
99− chip.setAttribute("aria-label", said.label + ": open the runner");
100− chip.title = "Open the runner";
107+ chip.setAttribute("aria-label", /runner/i.test(said.label) ? said.label : "Runner: " + said.label);
108+ chip.title = said.label;
101109 }
102110 // The app's settings, at the right of the bar, before the window's
103111 // buttons: where they are found without the menu.
188196 title.textContent = page.title || "g1t";
189197 middle.append(title);
190198 }
199+ chip.setAttribute("aria-expanded", page.runnerMenu ? "true" : "false");
191200 back.disabled = page.canGoBack === false;
192201 forward.disabled = page.canGoForward === false;
193202 setColors(page.colors);
+1−1
13301330
13311331 [[package]]
13321332 name = "g1t-desktop"
1333−version = "0.3.5"
1333+version = "0.3.6"
13341334 dependencies = [
13351335 "base64 0.22.1",
13361336 "ed25519-dalek",
+1−1
11 [package]
22 name = "g1t-desktop"
3−version = "0.3.5"
3+version = "0.3.6"
44 edition = "2024"
55 license = "MIT"
66 repository = "https://g1t.sh/flagon-io/g1t"
+1−0
2828 "runner_offer",
2929 "runner_register",
3030 "runner_control",
31+ "site_ready",
3132 ])))
3233 .expect("the app's build could not be prepared");
3334 }
+57−4
106106 checkForUpdates: function () {
107107 return invoke("check_for_updates");
108108 },
109− // The app's own settings, in a window of their own.
109+ // The app's own settings: the dialog this page draws (`settings.onOpen`),
110+ // or their own window when the page can't.
110111 openSettings: function () {
111112 quietly(invoke("open_settings"));
112113 },
114+ // The app's settings, for the dialog the site draws over itself, and
115+ // the runner's menu under the title bar's dot (src/settings.rs). The
116+ // page says it draws them with `ready`; until then they open in windows
117+ // of their own.
118+ settings: Object.freeze({
119+ ready: function (ready) {
120+ return invoke("site_ready", { ready: ready !== false });
121+ },
122+ get: function () {
123+ return invoke("get_settings");
124+ },
125+ // Every setting but opening at sign-in, as `get` has them (camelCase).
126+ save: function (settings) {
127+ return invoke("save_settings", { settings: settings });
128+ },
129+ setOpenAtLogin: function (on) {
130+ return invoke("set_open_at_login", { on: !!on });
131+ },
132+ // "current", "ready:<version>", or rejects with why; the dialog says it, so no notification.
133+ checkForUpdates: function () {
134+ return invoke("check_for_updates", { shown: true });
135+ },
136+ restart: function () {
137+ return invoke("restart");
138+ },
139+ restartToUpdate: function () {
140+ return invoke("restart_to_update");
141+ },
142+ changeServer: function () {
143+ return invoke("change_server");
144+ },
145+ // Open the dialog: { section: "runner" | "updates" | null, check: boolean }.
146+ onOpen: function (listener) {
147+ return on("g1t-open-settings", listener);
148+ },
149+ // The runner menu: { action: "toggle" | "open", at: share of the bar's width | null }.
150+ onRunnerMenu: function (listener) {
151+ return on("g1t-runner-menu", listener);
152+ },
153+ // An update finished downloading, with its version.
154+ onUpdateReady: function (listener) {
155+ return on("g1t-update-ready", listener);
156+ },
157+ // Whether the runner menu is open, which the title bar's dot shows.
158+ setRunnerMenuOpen: function (open) {
159+ runnerMenu = !!open;
160+ report();
161+ },
162+ }),
113163 // The title bar's search, in its middle: the site hands it a label
114164 // ("Search Flagon") and what opens its search. Returns what takes it away.
115165 setSearch: function (label, open) {
224274 // ── The title bar ─────────────────────────────────────────────────────
225275 // What the bar above the page shows of it: the title, the search the
226276 // site handed over (`setSearch`), whether there is a page to go back or
227− // forward to, and the page's colours, so the bar is the colour of g1t's
228− // rail, light or dark, or of whatever page is showing. Sent whenever any
229− // of it changes; the bar's search comes back as an event.
277+ // forward to, the page's colours, so the bar is the colour of g1t's
278+ // rail, light or dark, or of whatever page is showing, and whether the
279+ // runner menu is open under the bar's dot. Sent whenever any of it
280+ // changes; the bar's search comes back as an event.
230281 var search = null;
282+ var runnerMenu = false;
231283 var report = function () {};
232284 if (FRAME !== "custom") return;
233285
285337 canGoBack: nav && typeof nav.canGoBack === "boolean" ? nav.canGoBack : null,
286338 canGoForward: nav && typeof nav.canGoForward === "boolean" ? nav.canGoForward : null,
287339 colors: colors(),
340+ runnerMenu: runnerMenu,
288341 };
289342 var text = JSON.stringify(page);
290343 if (text === sent) return;
+67−16
4747 if config::is_server(app, &url) { Ok(()) } else { Err("only g1t's own pages may ask that".into()) }
4848 }
4949
50+/// The page asking is the server's, in the main window under the title
51+/// bar: the one that draws the settings dialog and the runner menu.
52+fn from_site_page(app: &AppHandle, webview: &Webview) -> Result<(), String> {
53+ from_site(app, webview)?;
54+ if webview.label() == frame::SITE { Ok(()) } else { Err("only g1t's page in the window may ask that".into()) }
55+}
56+
5057 /// The page asking is the launcher.
5158 fn from_launcher(webview: &Webview) -> Result<(), String> {
5259 let url = caller(webview)?;
163170 }
164171
165172 /// Looks for an update now: `current`, `ready:<version>`, or an error.
166−/// The settings window shows the answer itself; anyone else asking is also
167−/// told with a notification.
173+/// The settings, in their window or the dialog over the site (`shown`),
174+/// show the answer themselves; anyone else asking is also told with a
175+/// notification.
168176 #[tauri::command]
169−pub async fn check_for_updates(app: AppHandle, webview: Webview) -> Result<String, String> {
177+pub async fn check_for_updates(app: AppHandle, webview: Webview, shown: Option<bool>) -> Result<String, String> {
170178 from_either(&app, &webview)?;
171− updates::check(&app, webview.label() != settings::SETTINGS).await
179+ let shown = webview.label() == settings::SETTINGS || shown == Some(true);
180+ updates::check(&app, !shown).await
172181 }
173182
174183 /// A title bar button: `minimize`, `maximize` (which toggles), `close`,
202211 Ok(frame::current(&app))
203212 }
204213
205−/// A title bar button that acts on the page: `back`, `forward`, `search`, `focus`.
214+/// A title bar button that acts on the page: `back`, `forward`, `search`,
215+/// `focus`, `settings`, or `runner`, with `at` where its dot is across the
216+/// bar, as a share of the bar's width.
206217 #[tauri::command]
207−pub fn title_bar_act(app: AppHandle, webview: Webview, action: String) -> Result<(), String> {
218+pub fn title_bar_act(app: AppHandle, webview: Webview, action: String, at: Option<f64>) -> Result<(), String> {
208219 from_bar(&webview)?;
209− frame::act(&app, &action)
220+ frame::act(&app, &action, at)
210221 }
211222
212223 /// Restarts g1t, installing a downloaded update on the way: the settings
213224 /// window offers it after a change that needs one.
214225 #[tauri::command]
215226 pub fn restart(app: AppHandle, webview: Webview) -> Result<(), String> {
216− from_settings(&webview)?;
227+ from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
217228 if updates::ready(&app).is_some() {
218229 updates::restart(&app);
219230 }
224235 /// The settings window's "Restart now": installs the downloaded update and starts it.
225236 #[tauri::command]
226237 pub fn restart_to_update(app: AppHandle, webview: Webview) -> Result<(), String> {
227− from_settings(&webview)?;
238+ from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
228239 updates::restart(&app);
229240 Ok(())
230241 }
231242
232−/// Opens the app's settings window: the site's account menu asks for it.
243+/// Opens the app's settings: the site's account menu asks for it. They
244+/// open over the site, or in their own window when it can't draw them.
233245 #[tauri::command]
234246 pub fn open_settings(app: AppHandle, webview: Webview) -> Result<(), String> {
235247 from_either(&app, &webview)?;
237249 Ok(())
238250 }
239251
240−/// What the settings window shows.
252+/// The site's page says it draws the settings dialog and the runner menu
253+/// itself, once it can (true), or no longer (false). Until it says so, each
254+/// page loaded in the window has them open in windows of their own.
241255 #[tauri::command]
256+pub fn site_ready(app: AppHandle, webview: Webview, ready: bool) -> Result<(), String> {
257+ from_site_page(&app, &webview)?;
258+ config::set_site_ready(&app, ready);
259+ Ok(())
260+}
261+
262+/// What the settings show.
263+#[tauri::command]
242264 pub fn get_settings(app: AppHandle, webview: Webview) -> Result<settings::View, String> {
243− from_settings_or_runner(&webview)?;
265+ from_settings_or_runner(&webview).or_else(|_| from_site_page(&app, &webview))?;
244266 Ok(settings::view(&app))
245267 }
246268
247−/// The settings window changed something: everything but opening at sign-in.
269+/// The settings changed something: everything but opening at sign-in.
270+///
271+/// The site's page may change all of it but one thing: it can't take the
272+/// runner out of its sandbox. Jobs that run in Docker or directly on the
273+/// computer can reach the person's files, so that choice is made only in
274+/// the Runner window, a page bundled with the app, and never by a page
275+/// from the network, whatever it loads.
248276 #[tauri::command]
249277 pub fn save_settings(app: AppHandle, webview: Webview, settings: Settings) -> Result<settings::View, String> {
250− from_settings_or_runner(&webview)?;
278+ if from_settings_or_runner(&webview).is_err() {
279+ from_site_page(&app, &webview)?;
280+ if !site_may_choose(config::settings(&app).runner_backend, settings.runner_backend) {
281+ return Err("How jobs run outside the sandbox is chosen in the Runner window.".into());
282+ }
283+ }
251284 settings::apply(&app, settings)?;
252285 Ok(settings::view(&app))
253286 }
254287
288+/// Whether the site's page may change how jobs run from `before` to
289+/// `after`: keeping it, or putting it back in the sandbox.
290+fn site_may_choose(before: runner::Backend, after: runner::Backend) -> bool {
291+ after == before || after == runner::Backend::Sandboxed
292+}
293+
255294 /// Opening at sign-in, which the system keeps.
256295 #[tauri::command]
257296 pub fn set_open_at_login(app: AppHandle, webview: Webview, on: bool) -> Result<settings::View, String> {
258− from_settings(&webview)?;
297+ from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
259298 settings::open_at_login(&app, on)?;
260299 Ok(settings::view(&app))
261300 }
263302 /// The settings window's "Change…": the main window goes to the launcher's choice of server.
264303 #[tauri::command]
265304 pub fn change_server(app: AppHandle, webview: Webview) -> Result<(), String> {
266− from_settings(&webview)?;
305+ from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
267306 if let Some(window) = app.get_webview_window(settings::SETTINGS) {
268307 let _ = window.close();
269308 }
320359 assert!(super::script(false).contains(r#"var FRAME = "system";"#));
321360 assert!(script.contains(r#"var OUTSIDE = ["/pricing""#));
322361 }
362+
363+ #[test]
364+ fn the_site_cannot_take_the_runner_out_of_its_sandbox() {
365+ use crate::runner::Backend::{Docker, Host, Sandboxed};
366+ assert!(super::site_may_choose(Sandboxed, Sandboxed));
367+ assert!(super::site_may_choose(Host, Host));
368+ assert!(super::site_may_choose(Docker, Sandboxed));
369+ assert!(super::site_may_choose(Host, Sandboxed));
370+ assert!(!super::site_may_choose(Sandboxed, Host));
371+ assert!(!super::site_may_choose(Sandboxed, Docker));
372+ assert!(!super::site_may_choose(Docker, Host));
373+ }
323374 }
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…
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…
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…