Merge branch 'main' into rm-agents
57 files+481−14441 files not counted0/57 viewed
| 911 | 911 | ] | |
| 912 | 912 | ||
| 913 | 913 | [[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]] | |
| 914 | 920 | name = "des" | |
| 915 | 921 | version = "0.9.0" | |
| 916 | 922 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 1522 | 1528 | ||
| 1523 | 1529 | [[package]] | |
| 1524 | 1530 | name = "g1t-runner" | |
| 1525 | − | version = "0.3.2" | |
| 1531 | + | version = "0.3.3" | |
| 1526 | 1532 | dependencies = [ | |
| 1527 | 1533 | "anyhow", | |
| 1528 | 1534 | "base64 0.22.1", | |
| ⋯ | |||
| 1533 | 1539 | "g1t-scan", | |
| 1534 | 1540 | "hex", | |
| 1535 | 1541 | "libc", | |
| 1542 | + | "rcgen", | |
| 1536 | 1543 | "regex", | |
| 1544 | + | "rustls", | |
| 1537 | 1545 | "serde", | |
| 1538 | 1546 | "serde_json", | |
| 1539 | 1547 | "serde_yaml", | |
| 1540 | 1548 | "sha2 0.10.9", | |
| 1549 | + | "time", | |
| 1541 | 1550 | "toml", | |
| 1542 | 1551 | "ureq", | |
| 1552 | + | "webpki-roots 0.26.11", | |
| 1543 | 1553 | ] | |
| 1544 | 1554 | ||
| 1545 | 1555 | [[package]] | |
| ⋯ | |||
| 2531 | 2541 | ] | |
| 2532 | 2542 | ||
| 2533 | 2543 | [[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]] | |
| 2534 | 2550 | name = "num-integer" | |
| 2535 | 2551 | version = "0.1.47" | |
| 2536 | 2552 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2729 | 2745 | ] | |
| 2730 | 2746 | ||
| 2731 | 2747 | [[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]] | |
| 2732 | 2758 | name = "pem-rfc7468" | |
| 2733 | 2759 | version = "1.0.0" | |
| 2734 | 2760 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2909 | 2935 | ] | |
| 2910 | 2936 | ||
| 2911 | 2937 | [[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]] | |
| 2912 | 2944 | name = "primefield" | |
| 2913 | 2945 | version = "0.14.0" | |
| 2914 | 2946 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3061 | 3093 | ] | |
| 3062 | 3094 | ||
| 3063 | 3095 | [[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]] | |
| 3064 | 3109 | name = "redox_syscall" | |
| 3065 | 3110 | version = "0.5.18" | |
| 3066 | 3111 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 4068 | 4113 | ] | |
| 4069 | 4114 | ||
| 4070 | 4115 | [[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]] | |
| 4071 | 4135 | name = "tinystr" | |
| 4072 | 4136 | version = "0.8.4" | |
| 4073 | 4137 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 4940 | 5004 | ] | |
| 4941 | 5005 | ||
| 4942 | 5006 | [[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]] | |
| 4943 | 5016 | name = "yoke" | |
| 4944 | 5017 | version = "0.8.3" | |
| 4945 | 5018 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 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 | + | } |
| 12 | 12 | mod audit; | |
| 13 | 13 | mod billing; | |
| 14 | 14 | mod blobs; | |
| 15 | + | mod broker; | |
| 15 | 16 | mod checks; | |
| 16 | 17 | mod deployments; | |
| 17 | 18 | mod telemetry; | |
| ⋯ | |||
| 527 | 528 | let rest = rest.to_owned(); | |
| 528 | 529 | return blobs::for_job(request, env, &services, method, &rest).await; | |
| 529 | 530 | } | |
| 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 | + | } | |
| 530 | 540 | ||
| 531 | 541 | // A self-hosted runner, with a registration token or its own | |
| 532 | 542 | // credential, neither of which is a g1t access token. | |
| ⋯ | |||
| 667 | 677 | // `rotate`: a harness that takes a new token with the spec and | |
| 668 | 678 | // lets go of the one it asked with (plan.rs `job_spec`). | |
| 669 | 679 | 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); | |
| 670 | 683 | let answered: Outcome<Value> = g1t_kit::call( | |
| 671 | 684 | &services.actions, | |
| 672 | 685 | 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 }), | |
| 674 | 687 | ) | |
| 675 | 688 | .await?; | |
| 676 | 689 | return match answered { | |
| 1525 | 1525 | "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." | |
| 1526 | 1526 | } | |
| 1527 | 1527 | 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." | |
| 1529 | 1529 | } | |
| 1530 | 1530 | Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.", | |
| 1531 | 1531 | Op::ListActionsVariables => { |
| 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 | + | })(); |
| 1 | 1 | <!doctype html> | |
| 2 | 2 | <!-- | |
| 3 | 3 | 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 | |
| 6 | 8 | runs and the job each is running, how jobs run and for which workspace, | |
| 7 | 9 | its updates and its log, live (runner.js). Its words are the app's | |
| 8 | 10 | (src/runner/diagnosis.rs). | |
| ⋯ | |||
| 101 | 103 | <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/#the-runner">About the runner</a></p> | |
| 102 | 104 | </main> | |
| 103 | 105 | </div> | |
| 106 | + | <script src="busy.js"></script> | |
| 104 | 107 | <script src="runner.js"></script> | |
| 105 | 108 | </body> | |
| 106 | 109 | </html> | |
| 44 | 44 | return item; | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 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) { | |
| 49 | 67 | var button = document.createElement("button"); | |
| 50 | 68 | button.type = "button"; | |
| 51 | 69 | button.textContent = action.label; | |
| 52 | 70 | if (primary) button.className = "primary"; | |
| 71 | + | key = key + ":" + action.id; | |
| 72 | + | if (busy.pressed(key, status)) busy.start(button); | |
| 53 | 73 | button.addEventListener("click", function () { | |
| 54 | 74 | if (action.id === "choose_workspace") return byId("runnerWorkspace").focus(); | |
| 55 | 75 | 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 | + | }); | |
| 74 | 86 | }); | |
| 75 | 87 | return button; | |
| 76 | 88 | } | |
| 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."; | |
| 80 | 89 | ||
| 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 | − | ||
| 99 | 90 | function element(tag, className, text) { | |
| 100 | 91 | var item = document.createElement(tag); | |
| 101 | 92 | if (className) item.className = className; | |
| ⋯ | |||
| 133 | 124 | var ISOLATION = { "nested-container": "A fresh container in the VM", vm: "A VM of its own", container: "A container of its own", host: "None" }; | |
| 134 | 125 | var BUSY = { busy: true, starting: true, downloading: true, updating: true }; | |
| 135 | 126 | var cards = {}; | |
| 136 | − | var pending = {}; | |
| 137 | 127 | ||
| 138 | 128 | // A small arrow: the link goes out to g1t. | |
| 139 | 129 | function outward() { | |
| ⋯ | |||
| 249 | 239 | list.appendChild(row); | |
| 250 | 240 | } | |
| 251 | 241 | ||
| 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) { | |
| 254 | 245 | var on = kind.enabled; | |
| 255 | − | var waiting = pending[kind.id]; | |
| 256 | 246 | var button = element("button", on ? "quiet" : onFirst ? "primary" : null); | |
| 257 | 247 | button.type = "button"; | |
| 258 | − | button.textContent = waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start"; | |
| 248 | + | button.textContent = on ? "Stop" : "Start"; | |
| 259 | 249 | 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); | |
| 261 | 253 | 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); | |
| 273 | 257 | }); | |
| 274 | 258 | return button; | |
| 275 | 259 | } | |
| ⋯ | |||
| 289 | 273 | // What changes the buttons and the words is drawn again only when it changes, | |
| 290 | 274 | // so a button keeps its focus while the status ticks. | |
| 291 | 275 | 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]); | |
| 293 | 277 | if (signature === card.signature) return; | |
| 294 | 278 | card.signature = signature; | |
| 295 | 279 | ||
| ⋯ | |||
| 333 | 317 | // sees to it (waiting for the release's images), none does. | |
| 334 | 318 | var urgent = kind.tone === "problem" || kind.tone === "warning"; | |
| 335 | 319 | 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))); | |
| 337 | 321 | }); | |
| 338 | 322 | if (kind.can_toggle) { | |
| 339 | − | var toggle = toggleButton(kind, kind.actions.length === 0); | |
| 323 | + | var toggle = toggleButton(kind, kind.actions.length === 0, runner); | |
| 340 | 324 | toggle.classList.add("end"); | |
| 341 | 325 | actions.appendChild(toggle); | |
| 342 | 326 | } | |
| 343 | 327 | actions.hidden = !actions.firstChild; | |
| 344 | 328 | } | |
| 345 | 329 | ||
| 346 | − | var lastKinds = null; | |
| 347 | − | ||
| 348 | 330 | function renderKinds(runner) { | |
| 349 | − | lastKinds = runner; | |
| 350 | 331 | var list = byId("runner-kinds"); | |
| 351 | 332 | var kinds = (runner && runner.diagnosis.capabilities) || []; | |
| 352 | 333 | list.hidden = kinds.length === 0; | |
| ⋯ | |||
| 379 | 360 | var actions = byId("runner-actions"); | |
| 380 | 361 | actions.textContent = ""; | |
| 381 | 362 | diagnosis.actions.forEach(function (action, at) { | |
| 382 | − | actions.appendChild(actionButton(action, at === 0)); | |
| 363 | + | actions.appendChild(actionButton(action, at === 0, "runner", runnerStatus(runner))); | |
| 383 | 364 | }); | |
| 384 | 365 | renderKinds(runner); | |
| 385 | 366 | byId("runner").checked = !!runner.enabled; | |
| ⋯ | |||
| 414 | 395 | check.hidden = !update; | |
| 415 | 396 | if (update) { | |
| 416 | 397 | 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); | |
| 418 | 401 | } | |
| 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")); | |
| 419 | 404 | byId("runner-view").hidden = !runner.link; | |
| 420 | 405 | // The log follows along, unless it was scrolled back to read. | |
| 421 | 406 | var log = byId("runner-log"); | |
| ⋯ | |||
| 426 | 411 | ||
| 427 | 412 | byId("runnerBackend").addEventListener("change", save); | |
| 428 | 413 | 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); | |
| 441 | 423 | }); | |
| 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); | |
| 443 | 429 | ||
| 444 | 430 | // ── The whole runner, at the top ───────────────────────────────────── | |
| 445 | 431 | // As the title bar's chip says it, with what it is doing in a line and | |
| ⋯ | |||
| 458 | 444 | toggle.dataset.action = on ? "stop" : "start"; | |
| 459 | 445 | } | |
| 460 | 446 | ||
| 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); | |
| 470 | 450 | ||
| 471 | 451 | // Every setting, as the app has it now (the settings window changes the | |
| 472 | 452 | // rest), with the runner's own as they are on the page. | |
| ⋯ | |||
| 501 | 481 | events | |
| 502 | 482 | .listen("g1t-runner-status", function (event) { | |
| 503 | 483 | if (current) current.runnerView = event.payload; | |
| 504 | − | pending = {}; | |
| 505 | 484 | renderRunner(event.payload); | |
| 506 | 485 | }) | |
| 507 | 486 | .catch(function () {}); | |
| 129 | 129 | <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p> | |
| 130 | 130 | </main> | |
| 131 | 131 | </div> | |
| 132 | + | <script src="busy.js"></script> | |
| 132 | 133 | <script src="settings.js"></script> | |
| 133 | 134 | </body> | |
| 134 | 135 | </html> |
| 1 | 1 | // The settings window's work: show what the app has, and keep each change | |
| 2 | 2 | // 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). | |
| 5 | 8 | (function () { | |
| 6 | 9 | var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"]; | |
| 7 | 10 | // The settings the app keeps (src/settings.rs Settings); a save sends them all. | |
| ⋯ | |||
| 98 | 101 | } | |
| 99 | 102 | ||
| 100 | 103 | 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); | |
| 102 | 107 | }); | |
| 103 | 108 | ||
| 104 | 109 | SWITCHES.forEach(function (key) { | |
| ⋯ | |||
| 143 | 148 | checking = true; | |
| 144 | 149 | var button = byId("check"); | |
| 145 | 150 | var status = byId("update-status"); | |
| 146 | − | button.disabled = true; | |
| 151 | + | window.g1tBusy.start(button); | |
| 147 | 152 | status.textContent = "Checking, and downloading any new version…"; | |
| 148 | 153 | invoke("check_for_updates") | |
| 149 | 154 | .then(function (answer) { | |
| ⋯ | |||
| 158 | 163 | }) | |
| 159 | 164 | .then(function () { | |
| 160 | 165 | checking = false; | |
| 161 | − | button.disabled = false; | |
| 166 | + | window.g1tBusy.stop(button); | |
| 162 | 167 | }); | |
| 163 | 168 | } | |
| 164 | 169 | ||
| 165 | 170 | byId("check").addEventListener("click", check); | |
| 166 | 171 | byId("restart").addEventListener("click", function () { | |
| 167 | − | byId("restart").disabled = true; | |
| 172 | + | window.g1tBusy.start(byId("restart")); | |
| 168 | 173 | byId("update-status").textContent = "Installing…"; | |
| 169 | 174 | invoke("restart_to_update").catch(function (error) { | |
| 170 | − | byId("restart").disabled = false; | |
| 175 | + | window.g1tBusy.stop(byId("restart")); | |
| 171 | 176 | fail(error); | |
| 172 | 177 | }); | |
| 173 | 178 | }); | |
| ⋯ | |||
| 191 | 196 | } | |
| 192 | 197 | ||
| 193 | 198 | byId("restart-app").addEventListener("click", function () { | |
| 194 | − | byId("restart-app").disabled = true; | |
| 199 | + | window.g1tBusy.start(byId("restart-app")); | |
| 195 | 200 | invoke("restart").catch(function (error) { | |
| 196 | − | byId("restart-app").disabled = false; | |
| 201 | + | window.g1tBusy.stop(byId("restart-app")); | |
| 197 | 202 | fail(error); | |
| 198 | 203 | }); | |
| 199 | 204 | }); | |
| 200 | 205 | ||
| 201 | 206 | 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); | |
| 203 | 210 | }); | |
| 204 | 211 | ||
| 205 | 212 | // Links out, in the system's browser (src/bridge.js does the same on the site). | |
| 113 | 113 | outline: 2px solid var(--accent); | |
| 114 | 114 | outline-offset: -2px; | |
| 115 | 115 | } | |
| 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). */ | |
| 117 | 119 | .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; | |
| 122 | 124 | border-radius: 6px; | |
| 123 | 125 | white-space: nowrap; | |
| 124 | 126 | } | |
| 125 | 127 | .chip[hidden] { | |
| 126 | 128 | display: none; | |
| 127 | 129 | } | |
| 128 | − | .chip:hover { | |
| 130 | + | .chip:hover, | |
| 131 | + | .chip[aria-expanded="true"] { | |
| 129 | 132 | background: var(--hover); | |
| 130 | 133 | color: var(--fg); | |
| 131 | 134 | } | |
| 132 | 135 | .chip-dot { | |
| 133 | − | width: 7px; | |
| 134 | − | height: 7px; | |
| 136 | + | width: 8px; | |
| 137 | + | height: 8px; | |
| 138 | + | flex: none; | |
| 135 | 139 | border-radius: 50%; | |
| 136 | 140 | background: var(--muted); | |
| 137 | 141 | } | |
| ⋯ | |||
| 162 | 166 | animation: none; | |
| 163 | 167 | } | |
| 164 | 168 | } | |
| 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) { | |
| 167 | 175 | .chip-text { | |
| 168 | − | display: none; | |
| 176 | + | display: inline; | |
| 169 | 177 | } | |
| 170 | 178 | } | |
| 79 | 79 | }); | |
| 80 | 80 | left.append(back, forward); | |
| 81 | 81 | ||
| 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 })); | |
| 87 | 93 | }); | |
| 88 | 94 | chip.hidden = true; | |
| 95 | + | chip.setAttribute("aria-haspopup", "dialog"); | |
| 96 | + | chip.setAttribute("aria-expanded", "false"); | |
| 89 | 97 | var chipDot = document.createElement("span"); | |
| 90 | 98 | chipDot.className = "chip-dot"; | |
| 91 | 99 | var chipText = document.createElement("span"); | |
| ⋯ | |||
| 96 | 104 | chip.hidden = false; | |
| 97 | 105 | chip.dataset.tone = said.tone; | |
| 98 | 106 | 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; | |
| 101 | 109 | } | |
| 102 | 110 | // The app's settings, at the right of the bar, before the window's | |
| 103 | 111 | // buttons: where they are found without the menu. | |
| ⋯ | |||
| 188 | 196 | title.textContent = page.title || "g1t"; | |
| 189 | 197 | middle.append(title); | |
| 190 | 198 | } | |
| 199 | + | chip.setAttribute("aria-expanded", page.runnerMenu ? "true" : "false"); | |
| 191 | 200 | back.disabled = page.canGoBack === false; | |
| 192 | 201 | forward.disabled = page.canGoForward === false; | |
| 193 | 202 | setColors(page.colors); | |
| 1330 | 1330 | ||
| 1331 | 1331 | [[package]] | |
| 1332 | 1332 | name = "g1t-desktop" | |
| 1333 | − | version = "0.3.5" | |
| 1333 | + | version = "0.3.6" | |
| 1334 | 1334 | dependencies = [ | |
| 1335 | 1335 | "base64 0.22.1", | |
| 1336 | 1336 | "ed25519-dalek", |
| 1 | 1 | [package] | |
| 2 | 2 | name = "g1t-desktop" | |
| 3 | − | version = "0.3.5" | |
| 3 | + | version = "0.3.6" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license = "MIT" | |
| 6 | 6 | repository = "https://g1t.sh/flagon-io/g1t" |
| 28 | 28 | "runner_offer", | |
| 29 | 29 | "runner_register", | |
| 30 | 30 | "runner_control", | |
| 31 | + | "site_ready", | |
| 31 | 32 | ]))) | |
| 32 | 33 | .expect("the app's build could not be prepared"); | |
| 33 | 34 | } |
| 106 | 106 | checkForUpdates: function () { | |
| 107 | 107 | return invoke("check_for_updates"); | |
| 108 | 108 | }, | |
| 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. | |
| 110 | 111 | openSettings: function () { | |
| 111 | 112 | quietly(invoke("open_settings")); | |
| 112 | 113 | }, | |
| 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 | + | }), | |
| 113 | 163 | // The title bar's search, in its middle: the site hands it a label | |
| 114 | 164 | // ("Search Flagon") and what opens its search. Returns what takes it away. | |
| 115 | 165 | setSearch: function (label, open) { | |
| ⋯ | |||
| 224 | 274 | // ── The title bar ───────────────────────────────────────────────────── | |
| 225 | 275 | // What the bar above the page shows of it: the title, the search the | |
| 226 | 276 | // 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. | |
| 230 | 281 | var search = null; | |
| 282 | + | var runnerMenu = false; | |
| 231 | 283 | var report = function () {}; | |
| 232 | 284 | if (FRAME !== "custom") return; | |
| 233 | 285 | ||
| ⋯ | |||
| 285 | 337 | canGoBack: nav && typeof nav.canGoBack === "boolean" ? nav.canGoBack : null, | |
| 286 | 338 | canGoForward: nav && typeof nav.canGoForward === "boolean" ? nav.canGoForward : null, | |
| 287 | 339 | colors: colors(), | |
| 340 | + | runnerMenu: runnerMenu, | |
| 288 | 341 | }; | |
| 289 | 342 | var text = JSON.stringify(page); | |
| 290 | 343 | if (text === sent) return; | |
| 47 | 47 | if config::is_server(app, &url) { Ok(()) } else { Err("only g1t's own pages may ask that".into()) } | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 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 | + | ||
| 50 | 57 | /// The page asking is the launcher. | |
| 51 | 58 | fn from_launcher(webview: &Webview) -> Result<(), String> { | |
| 52 | 59 | let url = caller(webview)?; | |
| ⋯ | |||
| 163 | 170 | } | |
| 164 | 171 | ||
| 165 | 172 | /// 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. | |
| 168 | 176 | #[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> { | |
| 170 | 178 | 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 | |
| 172 | 181 | } | |
| 173 | 182 | ||
| 174 | 183 | /// A title bar button: `minimize`, `maximize` (which toggles), `close`, | |
| ⋯ | |||
| 202 | 211 | Ok(frame::current(&app)) | |
| 203 | 212 | } | |
| 204 | 213 | ||
| 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. | |
| 206 | 217 | #[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> { | |
| 208 | 219 | from_bar(&webview)?; | |
| 209 | − | frame::act(&app, &action) | |
| 220 | + | frame::act(&app, &action, at) | |
| 210 | 221 | } | |
| 211 | 222 | ||
| 212 | 223 | /// Restarts g1t, installing a downloaded update on the way: the settings | |
| 213 | 224 | /// window offers it after a change that needs one. | |
| 214 | 225 | #[tauri::command] | |
| 215 | 226 | pub fn restart(app: AppHandle, webview: Webview) -> Result<(), String> { | |
| 216 | − | from_settings(&webview)?; | |
| 227 | + | from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?; | |
| 217 | 228 | if updates::ready(&app).is_some() { | |
| 218 | 229 | updates::restart(&app); | |
| 219 | 230 | } | |
| ⋯ | |||
| 224 | 235 | /// The settings window's "Restart now": installs the downloaded update and starts it. | |
| 225 | 236 | #[tauri::command] | |
| 226 | 237 | 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))?; | |
| 228 | 239 | updates::restart(&app); | |
| 229 | 240 | Ok(()) | |
| 230 | 241 | } | |
| 231 | 242 | ||
| 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. | |
| 233 | 245 | #[tauri::command] | |
| 234 | 246 | pub fn open_settings(app: AppHandle, webview: Webview) -> Result<(), String> { | |
| 235 | 247 | from_either(&app, &webview)?; | |
| ⋯ | |||
| 237 | 249 | Ok(()) | |
| 238 | 250 | } | |
| 239 | 251 | ||
| 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. | |
| 241 | 255 | #[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] | |
| 242 | 264 | 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))?; | |
| 244 | 266 | Ok(settings::view(&app)) | |
| 245 | 267 | } | |
| 246 | 268 | ||
| 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. | |
| 248 | 276 | #[tauri::command] | |
| 249 | 277 | 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 | + | } | |
| 251 | 284 | settings::apply(&app, settings)?; | |
| 252 | 285 | Ok(settings::view(&app)) | |
| 253 | 286 | } | |
| 254 | 287 | ||
| 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 | + | ||
| 255 | 294 | /// Opening at sign-in, which the system keeps. | |
| 256 | 295 | #[tauri::command] | |
| 257 | 296 | 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))?; | |
| 259 | 298 | settings::open_at_login(&app, on)?; | |
| 260 | 299 | Ok(settings::view(&app)) | |
| 261 | 300 | } | |
| ⋯ | |||
| 263 | 302 | /// The settings window's "Change…": the main window goes to the launcher's choice of server. | |
| 264 | 303 | #[tauri::command] | |
| 265 | 304 | 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))?; | |
| 267 | 306 | if let Some(window) = app.get_webview_window(settings::SETTINGS) { | |
| 268 | 307 | let _ = window.close(); | |
| 269 | 308 | } | |
| ⋯ | |||
| 320 | 359 | assert!(super::script(false).contains(r#"var FRAME = "system";"#)); | |
| 321 | 360 | assert!(script.contains(r#"var OUTSIDE = ["/pricing""#)); | |
| 322 | 361 | } | |
| 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 | + | } | |
| 323 | 374 | } | |
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…