From d5eedf3481c16a3b25b6b15e85a5e0d32b735dec Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sun, 18 Oct 2026 21:27:15 +0800 Subject: [PATCH 1/8] Bound every CLI test run, not only refusals Without its arguments the binary falls back to web, which serves Setup until it is killed. A mutant that drops them, such as parse_agent_args returning nothing, then hangs whichever test ran the binary without a bound, and cargo mutants scores the caught mutant as a timeout, which fails the job. Only the refusal tests had a bound. Every run now goes through the bounded runner, which every caller already expected to exit, and so does the one test that started the binary itself. --- tests/cli.rs | 71 +++++++++++++--------------------------------------- 1 file changed, 17 insertions(+), 54 deletions(-) diff --git a/tests/cli.rs b/tests/cli.rs index cb2f029a..dfa4a3a6 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -15,7 +15,7 @@ use futures_util::{SinkExt, StreamExt}; use tokio_tungstenite::accept_async; fn run_cli_args(args: &[&str]) -> (i32, String, String) { - run_cli_args_with_env(args, &[]) + run_cli_until_exit(args, &[]) } fn build_version_line() -> String { @@ -61,9 +61,6 @@ fn only_sigkill_is_retryable() { /// exit status for a process that never ran, so /// `reject_usage_errors_with_exit_two` failed reporting 0 against 2 and read as /// the binary having accepted a usage error it had refused. -/// -/// Both runners share this, so the attempt bound and what a run out of attempts -/// says cannot drift apart between them. fn retry_on_sigkill(args: &[&str], mut once: impl FnMut() -> Option) -> T { for attempt in 0..5 { if let Some(result) = once() { @@ -74,49 +71,19 @@ fn retry_on_sigkill(args: &[&str], mut once: impl FnMut() -> Option) -> T unreachable!("the loop returns or asserts") } -fn run_cli_args_with_env(args: &[&str], envs: &[(&str, &str)]) -> (i32, String, String) { - retry_on_sigkill(args, || run_cli_args_with_env_once(args, envs)) -} - -fn run_cli_args_with_env_once( - args: &[&str], - envs: &[(&str, &str)], -) -> Option<(i32, String, String)> { - let cwd = exe_temp_path("empty-cwd"); - std::fs::create_dir_all(&cwd).expect("empty cwd should create"); - let output = cli_command(args, envs, &cwd) - .output() - .expect("codetrial should exit"); - let _ = std::fs::remove_dir_all(cwd); - - let Some(code) = output.status.code() else { - assert!( - was_sigkill(&output.status), - "{args:?}: killed by an unexpected signal: {}", - output.status - ); - return None; - }; - Some(( - code, - String::from_utf8(output.stdout).expect("stdout should be UTF-8"), - String::from_utf8(output.stderr).expect("stderr should be UTF-8"), - )) -} - -/// The same invocation, but the process is required to stop by itself. +/// Runs the binary and requires it to stop by itself, which every caller here +/// expects of it. /// -/// `run_cli_args` waits forever, which is right for a mode that always exits. -/// A refusal test is not that: what it asserts is that the binary *stops*, so a -/// regression letting it serve instead hangs the suite rather than failing it, -/// and `cargo mutants` scores that as a timeout instead of a caught mutant. -/// That is not hypothetical; it is how `replace == with != in run_web` reached -/// CI as a 33-second timeout. Bounded, so a guard that stopped guarding is a -/// red test. +/// Bounded for every caller, not only the refusal tests: a mutant or a +/// regression that loses the arguments falls back to `web`, which serves +/// Setup and never exits, so any unbounded run hangs the suite rather than +/// failing it, and `cargo mutants` scores that as a timeout instead of a +/// caught mutant. That is how `replace == with != in run_web`, and later a +/// `parse_agent_args` returning nothing, reached CI as timeouts. fn run_cli_until_exit(args: &[&str], envs: &[(&str, &str)]) -> (i32, String, String) { - // Retried on a kill for the same reason the other runner is: the machine - // running out of memory says nothing about the binary, and this helper's - // whole point is that a refusal is observed rather than assumed. + // Retried on a kill: the machine running out of memory says nothing about + // the binary, and this helper's whole point is that a refusal is observed + // rather than assumed. retry_on_sigkill(args, || run_cli_until_exit_once(args, envs)) } @@ -892,6 +859,7 @@ fn binary_accepts_dash_prefixed_values_through_the_attached_form() { let occupied = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let address = occupied.local_addr().unwrap().to_string(); write_config(&config, &dir, &address); + let (code, _, stderr) = run_cli_args(&[ "web", &format!("--config={}", config.to_str().unwrap()), @@ -1635,16 +1603,11 @@ fn binary_web_refuses_to_serve_setup_on_a_public_address() { /// in one start, and only the second one guarded. #[test] fn binary_web_refuses_a_public_setup_address_from_the_environment() { - let dir = exe_temp_path("setup-public-address-env"); - std::fs::create_dir_all(&dir).unwrap(); - - let output = cli_command(&["web"], &[("CODETRIAL_WEB_ADDR", "0.0.0.0:0")], &dir) - .output() - .expect("codetrial should exit"); - let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); - let _ = std::fs::remove_dir_all(&dir); + // Bounded: a launch that stopped reading the variable serves Setup on + // loopback instead, and must fail here rather than hang the suite. + let (code, _, stderr) = run_cli_until_exit(&["web"], &[("CODETRIAL_WEB_ADDR", "0.0.0.0:0")]); - assert_eq!(output.status.code(), Some(1), "{stderr}"); + assert_eq!(code, 1, "{stderr}"); assert!( stderr.contains("refusing to serve the Setup page"), "{stderr}" From 0b69e9df827e0ea723397708dd29f8a778fd0f5f Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sun, 18 Oct 2026 04:03:37 +0800 Subject: [PATCH 2/8] Answer only the Host names a server is given A loopback bind keeps the network out but not a web page: any origin can repoint its own name at 127.0.0.1 and then read the server as same origin, including the reports behind it. The Host header is the one thing that still carries the foreign name. CODETRIAL_ALLOWED_HOSTS, or --allowed-hosts, names the Host values the server answers; anything else gets 421 before any route runs. It is empty by default, and the check is not installed at all then, because a reverse proxy forwarding the public name is an ordinary deployment. A list that names no host is refused at startup instead of read as empty, so it cannot switch the check off unnoticed. --- README.md | 1 + config/codetrial.env.example | 4 +++ src/main.rs | 29 +++++++++++++++++++- src/web/mod.rs | 52 +++++++++++++++++++++++++++++++++++- tests/unit/bin.rs | 40 +++++++++++++++++++++++++++ tests/unit/web/policy.rs | 1 + tests/unit/web/pool.rs | 1 + tests/web.rs | 3 ++- tests/web/contract.rs | 2 ++ tests/web/policy.rs | 44 ++++++++++++++++++++++++++++++ 10 files changed, 174 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8ab82444..c6c995e9 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,7 @@ The common ones: | `CODETRIAL_GEMINI_CANDIDATE_VIDEO_ENABLED` | `false` | Forward candidate video to Gemini, one low-resolution frame in five seconds; ignored in a whiteboard interview | | `CODETRIAL_COMPILER_EXPLORER_ENABLED` | `true` | Enable remote C, C++, and Java runs | | `CODETRIAL_MAX_CONCURRENT_INTERVIEWS` | `16` | Interviews one `web` process hosts agents for | +| `CODETRIAL_ALLOWED_HOSTS` | empty | Comma-separated `Host` values the server answers; any other is refused with 421. Empty answers every name, but a value that names no host is refused at startup. `--allowed-hosts` sets it from the command line | Two more matter in production. `SESSION_SECRET` signs the session cookie, and its built-in default is a string published in this repository, so the server diff --git a/config/codetrial.env.example b/config/codetrial.env.example index c4b8785d..887d48ce 100644 --- a/config/codetrial.env.example +++ b/config/codetrial.env.example @@ -42,6 +42,10 @@ CODETRIAL_GEMINI_CANDIDATE_VIDEO_ENABLED=false # Number of proxies in front of the server. X-Forwarded-For is read only when # this is set, so a direct-facing deployment leaves it at 0 and cannot be spoofed. # CODETRIAL_TRUSTED_PROXY_HOPS=0 +# The Host names this server answers, comma separated, port included where the +# browser sends one. Anything else is refused, which stops a page that rebinds +# its own name to this address. Empty answers every name. +# CODETRIAL_ALLOWED_HOSTS= # Which extra LiveKit projects lead the rotation, comma separated. Only relevant # with config/codetrial.env. files; see docs/providers.md. # CODETRIAL_PROVIDER_ORDER= diff --git a/src/main.rs b/src/main.rs index 7dba37b3..2ed2f956 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ struct CliOptions { config_path: Option, web_addr: Option, web_dir: Option, + allowed_hosts: Option, room_prefix: Option, duration_min: Option, } @@ -201,6 +202,10 @@ fn parse_agent_args(args: &[String]) -> Result<(Vec, CliOptions), String match flag { "--config" => options.config_path = Some(value), "--web-addr" => options.web_addr = Some(value), + "--allowed-hosts" if value.trim().is_empty() => { + return Err("--allowed-hosts requires a Host".to_string()); + } + "--allowed-hosts" => options.allowed_hosts = Some(value), "--web-dir" => options.web_dir = Some(value), "--room-prefix" => options.room_prefix = Some(value), "--duration-min" => { @@ -236,7 +241,7 @@ fn print_help(usage: Option<&str>) { } } println!( - "\nOptions:\n --config PATH Use PATH instead of config/codetrial.env.local\n --web-addr ADDR Listen on ADDR\n --web-dir PATH Serve files from PATH\n --room-prefix PREFIX Prefix generated room names\n --duration-min MIN Set the interview duration\n -h, --help Show this help\n -V, --version Show the package version and build commit\n\nEvery option also takes `--flag=value`, which is the only way to pass a value\nthat starts with a dash. `--` ends the options." + "\nOptions:\n --config PATH Use PATH instead of config/codetrial.env.local\n --web-addr ADDR Listen on ADDR\n --allowed-hosts LIST Answer only these comma-separated Host values\n --web-dir PATH Serve files from PATH\n --room-prefix PREFIX Prefix generated room names\n --duration-min MIN Set the interview duration\n -h, --help Show this help\n -V, --version Show the package version and build commit\n\nEvery option also takes `--flag=value`, which is the only way to pass a value\nthat starts with a dash. `--` ends the options." ); } @@ -291,6 +296,7 @@ fn web_server_config( fixed_room_name: nonempty(values, "INTERVIEW_ROOM_NAME"), production, trusted_proxy_hops: trusted_proxy_hops(values), + allowed_hosts: allowed_hosts(values)?, compiler_explorer_enabled: compiler_explorer_enabled(values), guest_mode_enabled: guest_mode_enabled(values), recording, @@ -783,6 +789,9 @@ fn apply_options(values: &mut BTreeMap, options: &CliOptions) { if let Some(value) = &options.web_dir { values.insert("CODETRIAL_WEB_DIR".to_string(), value.clone()); } + if let Some(value) = &options.allowed_hosts { + values.insert("CODETRIAL_ALLOWED_HOSTS".to_string(), value.clone()); + } if let Some(value) = &options.room_prefix { values.insert("CODETRIAL_ROOM_PREFIX".to_string(), value.clone()); } @@ -1000,6 +1009,24 @@ fn interview_duration_min(values: &BTreeMap) -> u32 { .unwrap_or(DEFAULT_DURATION_MIN) } +/// `CODETRIAL_ALLOWED_HOSTS`, a comma-separated list of `Host` values. +/// +/// Unset or empty means no check. A value that holds only separators is +/// refused instead: it was meant to name a host, and reading it as no list +/// would answer every `Host` the person believes is refused. +fn allowed_hosts(values: &BTreeMap) -> Result, String> { + let hosts: Vec = value_or(values, "CODETRIAL_ALLOWED_HOSTS", "") + .split(',') + .map(str::trim) + .filter(|host| !host.is_empty()) + .map(str::to_string) + .collect(); + if hosts.is_empty() && nonempty(values, "CODETRIAL_ALLOWED_HOSTS").is_some() { + return Err("CODETRIAL_ALLOWED_HOSTS names no Host".to_string()); + } + Ok(hosts) +} + fn trusted_proxy_hops(values: &BTreeMap) -> u32 { nonempty(values, "CODETRIAL_TRUSTED_PROXY_HOPS") .and_then(|value| value.parse().ok()) diff --git a/src/web/mod.rs b/src/web/mod.rs index 46692526..b6050a68 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -77,6 +77,17 @@ pub struct WebServerConfig { /// client from `X-Forwarded-For`, which is only safe once every request is /// known to pass through that many proxies you control. pub trusted_proxy_hops: u32, + /// The `Host` values this server answers, compared whole and without case, + /// or empty to answer any. `CODETRIAL_ALLOWED_HOSTS` sets it. + /// + /// A loopback bind keeps the network out, but not a page on another origin: + /// that page can give its own name a short TTL, repoint it at 127.0.0.1 + /// and then read this server as same origin. The desktop app sets this so + /// such a page is refused, because it keeps a server on a fixed port for as + /// long as it is open and its reports sit behind that port. Empty by + /// default, because a reverse proxy that forwards the public name is an + /// ordinary deployment and would otherwise be refused. + pub allowed_hosts: Vec, /// Production recording, or `None` when it is off, which is every /// deployment that has not provisioned the resources in /// `docs/recording-contract.md`. @@ -131,6 +142,7 @@ impl fmt::Debug for WebServerConfig { compiler_explorer_enabled, guest_mode_enabled, trusted_proxy_hops, + allowed_hosts, recording, pool, probe_provider_quota, @@ -156,6 +168,7 @@ impl fmt::Debug for WebServerConfig { .field("compiler_explorer_enabled", compiler_explorer_enabled) .field("guest_mode_enabled", guest_mode_enabled) .field("trusted_proxy_hops", trusted_proxy_hops) + .field("allowed_hosts", allowed_hosts) .field("recording", recording) .field("pool", pool) .field("probe_provider_quota", probe_provider_quota) @@ -428,7 +441,7 @@ pub(crate) fn web_router( provider_quota.clone(), config.pool.clone(), )); - Router::new() + let router = Router::new() .route("/healthz", get(|| async { "ok\n" })) .route("/api/token", post(token_handler)) .route("/api/observer-token", post(observer_token_handler)) @@ -474,7 +487,20 @@ pub(crate) fn web_router( .layer(axum::middleware::from_fn_with_state( security_header_state(&config), security_headers, + )); + + // Only when there is a list, so a deployment that names no host pays + // nothing per request for a check that would pass everything. + let router = if config.allowed_hosts.is_empty() { + router + } else { + router.layer(axum::middleware::from_fn_with_state( + Arc::new(config.allowed_hosts.clone()), + allowed_host, )) + }; + + router // Outermost, so it sees the finished response whatever produced it. // `web/` is 13 MB, over 11 MB of that wasm, and shipping it verbatim is // the one cost a candidate pays before the timer is useful to them. @@ -506,6 +532,30 @@ pub(crate) fn web_router( }) } +/// Refuses a request whose `Host` is not on [`WebServerConfig::allowed_hosts`], +/// before any route sees it, `/healthz` included. +/// +/// 421 rather than 403: the request is not forbidden to its sender, it reached +/// a server that does not answer to the name it used. +async fn allowed_host( + axum::extract::State(allowed): axum::extract::State>>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + if host_is_allowed(&allowed, request.headers().get(header::HOST)) { + return next.run(request).await; + } + json_response( + StatusCode::MISDIRECTED_REQUEST, + json!({ "error": "This server does not answer to that host name." }), + ) +} + +fn host_is_allowed(allowed: &[String], host: Option<&axum::http::HeaderValue>) -> bool { + host.and_then(|value| value.to_str().ok()) + .is_some_and(|host| allowed.iter().any(|name| name.eq_ignore_ascii_case(host))) +} + impl AppState { /// Whether this deployment allows callers to use guest mode. fn guest_mode_available(&self) -> bool { diff --git a/tests/unit/bin.rs b/tests/unit/bin.rs index a037f9d3..6b7bcc1c 100644 --- a/tests/unit/bin.rs +++ b/tests/unit/bin.rs @@ -567,3 +567,43 @@ fn guest_mode_recording_conflict_requires_both_settings() { assert!(!super::guest_mode_recording_conflict(true, false)); assert!(super::guest_mode_recording_conflict(true, true)); } + +/// Written by hand in a config file or by a launcher, so the spacing and a +/// trailing comma are the writer's, and neither may become a host name that +/// matches a request with an empty `Host`. +#[test] +fn the_allowed_hosts_are_a_trimmed_list_without_blanks() { + assert!(super::allowed_hosts(&values(&[])).unwrap().is_empty()); + assert_eq!( + super::allowed_hosts(&values(&[( + "CODETRIAL_ALLOWED_HOSTS", + " 127.0.0.1:3101 ,, Desktop.Test ," + )])) + .unwrap(), + ["127.0.0.1:3101", "Desktop.Test"] + ); +} + +/// A list that parses to nothing would switch the check off while the person +/// who wrote it believes every other name is refused. +#[test] +fn an_allowed_hosts_list_with_no_host_is_refused_not_read_as_off() { + // Nothing, or only spacing, is an unset variable, as for every other + // setting. + for unset in ["", " "] { + assert!( + super::allowed_hosts(&values(&[("CODETRIAL_ALLOWED_HOSTS", unset)])) + .unwrap() + .is_empty(), + "{unset:?}" + ); + } + for blank in [" , ", ",,"] { + assert!( + super::allowed_hosts(&values(&[("CODETRIAL_ALLOWED_HOSTS", blank)])).is_err(), + "{blank:?}" + ); + } + let argv = ["web", "--allowed-hosts", " "].map(String::from); + assert!(super::parse_agent_args(&argv).is_err()); +} diff --git a/tests/unit/web/policy.rs b/tests/unit/web/policy.rs index 2ac814a9..642e4a8f 100644 --- a/tests/unit/web/policy.rs +++ b/tests/unit/web/policy.rs @@ -24,6 +24,7 @@ fn policy_for(url: &str) -> String { compiler_explorer_enabled: false, guest_mode_enabled: false, trusted_proxy_hops: 0, + allowed_hosts: Vec::new(), recording: None, pool: crate::config::ProviderPool { providers: vec![crate::config::Provider { diff --git a/tests/unit/web/pool.rs b/tests/unit/web/pool.rs index 4f632f5d..b0bd958a 100644 --- a/tests/unit/web/pool.rs +++ b/tests/unit/web/pool.rs @@ -31,6 +31,7 @@ fn config_with(ids: &[&str]) -> WebServerConfig { compiler_explorer_enabled: false, guest_mode_enabled: false, trusted_proxy_hops: 0, + allowed_hosts: Vec::new(), recording: None, probe_provider_quota: false, pool: ProviderPool { diff --git a/tests/web.rs b/tests/web.rs index c62df82a..4cece48c 100644 --- a/tests/web.rs +++ b/tests/web.rs @@ -136,6 +136,7 @@ fn web_config() -> WebServerConfig { compiler_explorer_enabled: true, guest_mode_enabled: true, trusted_proxy_hops: 0, + allowed_hosts: Vec::new(), recording: None, pool: primary_pool("wss://example.livekit.cloud", "devkey", "devsecret"), probe_provider_quota: false, @@ -909,7 +910,7 @@ fn query_parameter(location: &str, name: &str) -> Option { fn declared_routes() -> Vec { let source = fs::read_to_string("src/web/mod.rs").unwrap(); let router = source - .split_once(" Router::new()") + .split_once(" let router = Router::new()") .expect("web_router builds a Router") .1 .split_once(" .fallback(") diff --git a/tests/web/contract.rs b/tests/web/contract.rs index 6e1d441b..2a7e67b5 100644 --- a/tests/web/contract.rs +++ b/tests/web/contract.rs @@ -58,6 +58,7 @@ async fn static_home_markup_matches_frontend_contract() { compiler_explorer_enabled: true, guest_mode_enabled: false, trusted_proxy_hops: 0, + allowed_hosts: Vec::new(), recording: None, pool: Default::default(), probe_provider_quota: false, @@ -113,6 +114,7 @@ async fn static_interview_markup_exposes_offline_surface() { compiler_explorer_enabled: true, guest_mode_enabled: false, trusted_proxy_hops: 0, + allowed_hosts: Vec::new(), recording: None, pool: Default::default(), probe_provider_quota: false, diff --git a/tests/web/policy.rs b/tests/web/policy.rs index 3aca85c0..434f4df0 100644 --- a/tests/web/policy.rs +++ b/tests/web/policy.rs @@ -287,3 +287,47 @@ async fn the_recording_template_is_reachable_under_a_policy_that_permits_its_roo server.shutdown().await; } + +/// A page on another origin can give its own name a short TTL, repoint it at +/// 127.0.0.1 and then read a loopback server as same origin. The `Host` it +/// sends still carries that name, so a server told which names are its own +/// refuses the rest, `/healthz` included, and matches without case because a +/// host name has none. +#[tokio::test] +async fn an_allowed_hosts_list_refuses_every_other_host_name() { + let (base, server) = spawn_web_server(WebServerConfig { + allowed_hosts: vec!["desktop.test:3101".to_string()], + ..web_config() + }) + .await; + let status = |host: &'static str| { + let request = http_client() + .get(format!("{base}/healthz")) + .header(reqwest::header::HOST, host); + async move { request.send().await.unwrap().status().as_u16() } + }; + + assert_eq!(status("Desktop.Test:3101").await, 200); + assert_eq!(status("rebound.example:3101").await, 421); + assert_eq!(status("desktop.test:3102").await, 421); + // Near misses are other names: a trailing dot, a bare IPv6 literal. + assert_eq!(status("desktop.test.:3101").await, 421); + assert_eq!(status("[::1]:3101").await, 421); + + // No `Host` at all, which an HTTP/1.0 request may omit, names nobody the + // server answers to. + let addr = base.strip_prefix("http://").unwrap().to_string(); + let reply = tokio::task::spawn_blocking(move || { + use std::io::{Read as _, Write as _}; + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + stream.write_all(b"GET /healthz HTTP/1.0\r\n\r\n").unwrap(); + let mut reply = String::new(); + stream.read_to_string(&mut reply).unwrap(); + reply + }) + .await + .unwrap(); + assert!(reply.starts_with("HTTP/1.0 421"), "{reply}"); + + server.shutdown().await; +} From cd0149f36e41e1872d4c87c047da08ed272d7318 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sun, 18 Oct 2026 04:04:03 +0800 Subject: [PATCH 3/8] Keep all of a launch's state in one data folder A program that starts the server for someone, rather than an operator in a shell, chooses neither the working directory nor the folder the executable sits in, and both decide where the CLI looks for its config and writes its database, cold-start config and error log. An installed app's executable folder is read-only or inside a bundle. --data-dir DIR puts all of that in DIR and searches nowhere else. Such a launch also reads no configuration from the environment, so a shell that exported CODETRIAL_DB_PATH or NODE_ENV for a CLI deployment cannot move the install's reports depending on how it was started, and with no SESSION_SECRET in its config it makes and keeps its own key in DIR instead of signing cookies with the published default. A first launch into a folder that does not exist yet creates it for the error log, so the reason it failed is where its owner will look. One thing is taken from the environment: a first launch with the three LiveKit keys exported writes them, and the Google keys, into DIR's config as Setup would, and skips Setup. Without the flag nothing changes. That file and the key are written by the helper Setup now shares, owner-only and never over an existing name, which also removes a file a failed write cut short. The CLI tests start the server with an empty environment instead of a list of keys that had already fallen behind. --- src/lib.rs | 29 ++++ src/main.rs | 193 ++++++++++++++++++++--- src/web/mod.rs | 2 +- src/web/setup.rs | 173 ++++++++++++--------- tests/cli.rs | 331 ++++++++++++++++++++++++++++++++++++---- tests/unit/bin.rs | 120 +++++++++++++++ tests/unit/web/setup.rs | 79 ++++++++++ 7 files changed, 808 insertions(+), 119 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 465d1f4e..a6ca1ad2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,35 @@ pub fn exe_dir() -> std::path::PathBuf { .unwrap_or_else(|| std::path::PathBuf::from(".")) } +/// Writes a new file that only its owner can read, and refuses rather than +/// replacing one: the config file Setup writes and the session key a +/// `--data-dir` launch makes both hold secrets. +/// +/// `create_new`, so an existing name is reported rather than followed and +/// truncated. `is_cold_start` reached Setup by finding no config, and the +/// `is_file` it asked answers no for a symlink with nothing at the end of it: +/// without `O_EXCL` a dangling one planted there sends the credentials wherever +/// it points. +/// +/// A file cut short is removed: the next launch would find it, skip Setup or +/// refuse the key, and never start again until someone deleted it by hand. +pub fn create_owner_only(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + // Otherwise the umask decides, and its usual answer is world-readable. + // Windows has no equivalent here and inherits the folder's ACL. + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + std::io::Write::write_all(&mut file, contents).inspect_err(|_| { + drop(file); + let _ = std::fs::remove_file(path); + }) +} + /// Encode one URI component using only RFC 3986 unreserved bytes verbatim. /// Both object paths and credential query values must escape slashes and /// pluses. diff --git a/src/main.rs b/src/main.rs index 2ed2f956..796c5391 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,10 +20,14 @@ const CONFIG_FILE_NAME: &str = "codetrial.env.local"; const CONFIG_EXAMPLE_NAME: &str = "codetrial.env.example"; const DEFAULT_ACCOUNT_DB_PATH: &str = "codetrial.db"; const DEFAULT_SESSION_SECRET: &str = "codetrial-local-session"; +/// Where a `--data-dir` install keeps the key its session cookies are signed +/// with, when its config file does not set `SESSION_SECRET`. +const SESSION_SECRET_FILE: &str = "session-secret"; #[derive(Debug, Default, Clone)] struct CliOptions { config_path: Option, + data_dir: Option, web_addr: Option, web_dir: Option, allowed_hosts: Option, @@ -40,8 +44,9 @@ struct CliOptions { const MODES: [(&str, usize, &str, ModeFn); 3] = [ ("web", 1, "codetrial web [OPTIONS]", |_, options| { // Logged for `web` only: `run-livekit`/`check-gemini` are terminal-run - // tooling. - run_web(options).inspect_err(|error| log_web_error(error)) + // tooling. The path is taken before `run_web` owns the options. + let log = web_error_log_path(&options); + run_web(options).inspect_err(|error| log_web_error(&log, error)) }), ( "run-livekit", @@ -201,6 +206,7 @@ fn parse_agent_args(args: &[String]) -> Result<(Vec, CliOptions), String }; match flag { "--config" => options.config_path = Some(value), + "--data-dir" => options.data_dir = Some(PathBuf::from(value)), "--web-addr" => options.web_addr = Some(value), "--allowed-hosts" if value.trim().is_empty() => { return Err("--allowed-hosts requires a Host".to_string()); @@ -220,6 +226,18 @@ fn parse_agent_args(args: &[String]) -> Result<(Vec, CliOptions), String _ => return Err(format!("unknown flag: {flag}")), } } + + // Two answers to where the config file is. `--data-dir` exists so that a + // launcher can say everything lives in one folder, and a `--config` beside + // it would put the file somewhere else while the database and the log + // stayed behind. + if options.config_path.is_some() && options.data_dir.is_some() { + return Err( + "--config and --data-dir cannot be combined: --data-dir already names the \ + folder the config file is read from" + .to_string(), + ); + } Ok((positionals, options)) } @@ -241,7 +259,7 @@ fn print_help(usage: Option<&str>) { } } println!( - "\nOptions:\n --config PATH Use PATH instead of config/codetrial.env.local\n --web-addr ADDR Listen on ADDR\n --allowed-hosts LIST Answer only these comma-separated Host values\n --web-dir PATH Serve files from PATH\n --room-prefix PREFIX Prefix generated room names\n --duration-min MIN Set the interview duration\n -h, --help Show this help\n -V, --version Show the package version and build commit\n\nEvery option also takes `--flag=value`, which is the only way to pass a value\nthat starts with a dash. `--` ends the options." + "\nOptions:\n --config PATH Use PATH instead of config/codetrial.env.local\n --data-dir DIR Keep the config, accounts and error log in DIR only\n --web-addr ADDR Listen on ADDR\n --allowed-hosts LIST Answer only these comma-separated Host values\n --web-dir PATH Serve files from PATH\n --room-prefix PREFIX Prefix generated room names\n --duration-min MIN Set the interview duration\n -h, --help Show this help\n -V, --version Show the package version and build commit\n\nEvery option also takes `--flag=value`, which is the only way to pass a value\nthat starts with a dash. `--` ends the options." ); } @@ -285,7 +303,17 @@ fn web_server_config( recording_can_deliver(recording.as_ref())?; Ok(WebServerConfig { - web_dir: PathBuf::from(value_or(values, "CODETRIAL_WEB_DIR", DEFAULT_WEB_DIR)), + web_dir: match nonempty(values, "CODETRIAL_WEB_DIR") { + Some(dir) => PathBuf::from(dir), + + // Under the data directory rather than the working one, which a + // launcher does not choose: a `web/` that happens to sit where the + // app was started would otherwise replace the embedded pages. + None => match &options.data_dir { + Some(dir) => dir.join(DEFAULT_WEB_DIR), + None => PathBuf::from(DEFAULT_WEB_DIR), + }, + }, github_client_id: nonempty(values, "GITHUB_CLIENT_ID"), github_client_secret: nonempty(values, "GITHUB_CLIENT_SECRET"), session_secret: Some(value_or(values, "SESSION_SECRET", DEFAULT_SESSION_SECRET)), @@ -311,8 +339,9 @@ fn run_web(options: CliOptions) -> Result<(), String> { // Why that socket is carried here rather than rebound: `serve_setup`. Read // once, so the question and the page it opens answer from the same map. let before_config = environment_and_flags(&options); + data_dir_config_from_environment(&options, is_production(&before_config))?; let handed_over = if is_cold_start(&options, &before_config) { - Some(serve_setup(&before_config)?) + Some(serve_setup(&before_config, &options)?) } else { None }; @@ -383,7 +412,7 @@ fn run_web(options: CliOptions) -> Result<(), String> { // next thing to go wrong sends them back to a message from days ago. This // is the only moment that can tell those apart, and the error text points // at nothing else. - clear_web_error_log(); + clear_web_error_log(&options); // Whether this process also hosts interviewers. A full agent config, which // is a Gemini key on top of what the web side needs, means yes; without it @@ -524,7 +553,10 @@ fn environment_supplies_credentials(values: &BTreeMap) -> bool { /// connections this page just served, and `TIME_WAIT` outlasts any retry worth /// writing. The loser has already written the config, so it has also spent the /// cold start that would have brought Setup back. -fn serve_setup(values: &BTreeMap) -> Result { +fn serve_setup( + values: &BTreeMap, + options: &CliOptions, +) -> Result { let listener = bind_web_listener(&value_or(values, "CODETRIAL_WEB_ADDR", DEFAULT_WEB_ADDR))?; // Fails closed on an address the socket cannot name, for the reason @@ -568,7 +600,7 @@ fn serve_setup(values: &BTreeMap) -> Result std::path::PathBuf { - codetrial::exe_dir().join("codetrial-error.log") +/// In the data directory when there is one: an installed app's executable +/// folder is read-only, or inside an application bundle nobody opens. +fn web_error_log_path(options: &CliOptions) -> std::path::PathBuf { + match &options.data_dir { + Some(dir) => dir.join("codetrial-error.log"), + None => codetrial::exe_dir().join("codetrial-error.log"), + } } fn run_livekit(config: AgentConfig, room_name: &str) -> Result<(), String> { @@ -763,19 +802,112 @@ fn load_agent_config(options: &CliOptions) -> Result { /// `binary_web_reads_deployment_keys_from_the_config_file` refuses, because a /// key set in the config file was once silently a no-op. fn environment_and_flags(options: &CliOptions) -> BTreeMap { - let mut values = std::env::vars().collect::>(); + let mut values = environment(options); apply_options(&mut values, options); values } +/// A first `--data-dir` launch started where the credentials Setup asks for +/// are already exported writes them into the folder's config, as Setup would +/// have, instead of asking for them again; every later launch then finds the +/// file, including one started from a desktop that exports nothing. +/// +/// The one exception to `environment` ignoring the process environment, and a +/// narrow one: only Setup's own keys, only while the folder has no config, and +/// written down rather than read live, so the file stays the whole of the +/// install's configuration. +fn data_dir_config_from_environment(options: &CliOptions, production: bool) -> Result<(), String> { + let Some(dir) = &options.data_dir else { + return Ok(()); + }; + let path = dir.join(CONFIG_FILE_NAME); + if path.exists() { + return Ok(()); + } + let exported = std::env::vars().collect(); + let Some(contents) = + codetrial::web::config_from_values(&exported, production).map_err(|error| { + format!("the exported Setup keys cannot be written to a config: {error}") + })? + else { + return Ok(()); + }; + std::fs::create_dir_all(dir) + .and_then(|()| codetrial::create_owner_only(&path, contents.as_bytes())) + .map_err(|error| format!("cannot write {}: {error}", path.display()))?; + println!( + "codetrial: wrote {} from the exported LiveKit and Google keys", + path.display() + ); + Ok(()) +} + +/// The process environment, or nothing for a `--data-dir` launch. +/// +/// A launcher that names the folder is saying the install's configuration is +/// in it, and an environment it was started from is not part of that: a shell +/// that exported `CODETRIAL_DB_PATH` or `NODE_ENV` for a CLI deployment would +/// otherwise move this install's reports, or change its rules, depending on +/// how it was started. Decided here, once, rather than by every launcher +/// keeping its own list of the keys this program reads. +fn environment(options: &CliOptions) -> BTreeMap { + if options.data_dir.is_some() { + return BTreeMap::new(); + } + std::env::vars().collect() +} + fn load_values(options: &CliOptions) -> Result, String> { - let mut values = std::env::vars().collect::>(); + let mut values = environment(options); let path = primary_config_path(options)?; codetrial::config::apply_config_file(&mut values, codetrial::config::read_config_file(&path)?); apply_options(&mut values, options); + if let Some(dir) = &options.data_dir + && nonempty(&values, "SESSION_SECRET").is_none() + { + let secret = data_dir_session_secret(&dir.join(SESSION_SECRET_FILE))?; + values.insert("SESSION_SECRET".to_string(), secret); + } Ok(values) } +/// The key a `--data-dir` install signs session cookies with, made on its first +/// launch and reused, so a relaunch keeps everyone signed in. +/// +/// Not the built-in default: that one is published, and any program on the +/// machine can reach a loopback port to present a cookie signed with it. +/// Owner-only on Unix for the same reason. A file that is not a key this wrote +/// is refused rather than replaced, because replacing it signs everyone out +/// and hides whatever damaged it. +fn data_dir_session_secret(path: &std::path::Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => { + let secret = text.trim(); + if secret.len() >= 32 + && secret + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + Ok(secret.to_string()) + } else { + Err(format!( + "{} is not a session key this program wrote. Move it aside to make a new \ + one; everyone will be signed out.", + path.display() + )) + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let secret = codetrial::accounts::random_token(32) + .map_err(|error| format!("cannot generate a session key: {error}"))?; + codetrial::create_owner_only(path, secret.as_bytes()) + .map_err(|error| format!("cannot write {}: {error}", path.display()))?; + Ok(secret) + } + Err(error) => Err(format!("cannot read {}: {error}", path.display())), + } +} + /// The flags, over whatever the environment and the config file said. /// /// Split out because a cold start has to build the same map without the file @@ -801,6 +933,20 @@ fn apply_options(values: &mut BTreeMap, options: &CliOptions) { } fn primary_config_path(options: &CliOptions) -> Result { + // The one place searched, and not one place among several: the working + // directory and the executable's folder are whatever the launcher left them + // as, so a config found there would belong to some other install. + if let Some(dir) = &options.data_dir { + let path = dir.join(CONFIG_FILE_NAME); + if path.is_file() { + return Ok(path); + } + return Err(format!( + "required configuration file is missing: {}; write one with LIVEKIT_URL, \ + LIVEKIT_API_KEY and LIVEKIT_API_SECRET", + path.display() + )); + } if let Some(path) = &options.config_path { let path = PathBuf::from(path); if !path.is_file() { @@ -859,6 +1005,10 @@ fn primary_config_path(options: &CliOptions) -> Result { /// a `--config` pointing somewhere a process may not write is a startup /// failure that names the directory, and `CODETRIAL_DB_PATH` is the way out. fn config_dir(options: &CliOptions) -> PathBuf { + if let Some(dir) = &options.data_dir { + return dir.clone(); + } + // The search order comes from primary_config_path rather than being written // out again here. Spelled twice, it was free to drift, and the drift is // silent: the server keeps reading its own config while the pool quietly @@ -1195,7 +1345,10 @@ fn account_db_path(values: &BTreeMap, options: &CliOptions) -> P /// existing: a released binary run from a directory that happens to hold one /// would otherwise write the credentials there and lose them the moment it was /// next started from somewhere else. -fn cold_start_config_path() -> PathBuf { +fn cold_start_config_path(options: &CliOptions) -> PathBuf { + if let Some(dir) = &options.data_dir { + return dir.join(CONFIG_FILE_NAME); + } let in_checkout = PathBuf::from(CONFIG_DIR); if in_checkout.join(CONFIG_EXAMPLE_NAME).is_file() { return in_checkout.join(CONFIG_FILE_NAME); diff --git a/src/web/mod.rs b/src/web/mod.rs index b6050a68..355c6606 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -42,7 +42,7 @@ pub use auth::login_config; pub use interviews::REPLAY_RATE_LIMIT; use pool::{ProviderQuota, spawn_provider_quota_refresher}; pub use recordings::READ_RATE_LIMIT; -pub use setup::setup_service; +pub use setup::{config_from_values, setup_service}; pub use token::{TOKEN_RATE_LIMIT, TokenConfig, TokenResponse, token_response}; pub const MAX_BODY_BYTES: usize = 8 * 1024; diff --git a/src/web/setup.rs b/src/web/setup.rs index 58723b0d..ab8879f7 100644 --- a/src/web/setup.rs +++ b/src/web/setup.rs @@ -400,6 +400,45 @@ impl SetupFields { } pairs } + + /// The file, from `pairs`, so it holds exactly the config that was checked. + /// Every value is known to carry no newline by now, which is what makes one + /// line per key a faithful encoding of it. + fn contents(&self) -> String { + self.pairs() + .iter() + .map(|(key, value)| format!("{key}={value}\n")) + .collect() + } +} + +/// Why `value` cannot be written as a `KEY=value` line of the config file and +/// read back as itself, if it cannot. +/// +/// These are read back by `read_config_file`, which parses one line at a +/// time: a newline inside a value is a second line, and a second line is a +/// config key nobody submitted. `SESSION_SECRET` is one such key, and this +/// process reads the file it just wrote. +fn unwritable(key: &str, value: &str) -> Option { + if value.chars().any(char::is_control) { + return Some(format!("{key} must not contain control characters")); + } + + // `read_config_file` does `trim_matches('"')` then `trim_matches('\'')`, so + // a value edged with either is probed as one string and loaded as another. + // Refused rather than written, for the reason the newline above is: this + // file format cannot carry the value, and answering 200 would mean the file + // does not hold what was checked. Refusing says so while there is still a + // form to say it in. + ['"', '\''] + .into_iter() + .find(|quote| value.starts_with(*quote) || value.ends_with(*quote)) + .map(|edge| { + format!( + "{key} must not start or end with {edge}: the config file drops a quote at \ + either end, so the value read back would not be the one checked here" + ) + }) } /// Everything a submission can be refused for before anything is sent or @@ -420,8 +459,7 @@ impl SetupFields { /// because `read_config_file` trims what it reads. And a `googleApiKey` of /// spaces is not empty to `is_empty`, so it takes the branch that probes Gemini /// and fails there for a field the form calls optional. -#[allow(clippy::result_large_err)] // Responses are immediately returned by HTTP handlers. -fn validated_fields(submission: &Value) -> Result { +fn validated_fields(submission: &Value) -> Result { let field = |key: &str| { submission .get(key) @@ -440,52 +478,20 @@ fn validated_fields(submission: &Value) -> Result { // calls it missing, so accepting one here would write a file the launch // on the other side of this page refuses. if field(key).is_empty() { - return Err(super::json_response( - StatusCode::BAD_REQUEST, - json!({ "error": format!("{key} is required") }), - )); + return Err(format!("{key} is required")); } } // Every field, including the optional one, and before anything is sent - // anywhere. These are written as `KEY=value` lines below and read back by - // `read_config_file`, which parses one line at a time: a newline inside a - // value is a second line, and a second line is a config key nobody - // submitted. `SESSION_SECRET` is one such key, and this process reads the - // file it just wrote. + // anywhere. for key in [ "livekitUrl", "livekitApiKey", "livekitApiSecret", "googleApiKey", ] { - if field(key).chars().any(char::is_control) { - return Err(super::json_response( - StatusCode::BAD_REQUEST, - json!({ "error": format!("{key} must not contain control characters") }), - )); - } - - // `read_config_file` does `trim_matches('"')` then - // `trim_matches('\'')`, so a value edged with either is probed as one - // string and loaded as another. Refused rather than written, for the - // reason the newline above is: this file format cannot carry the value, - // and answering 200 would mean the file does not hold what was checked. - // Refusing says so while there is still a form to say it in. - if let Some(edge) = ['"', '\''] - .into_iter() - .find(|quote| field(key).starts_with(*quote) || field(key).ends_with(*quote)) - { - return Err(super::json_response( - StatusCode::BAD_REQUEST, - json!({ - "error": format!( - "{key} must not start or end with {edge}: the config file drops \ - a quote at either end, so the value read back would not be the \ - one checked here" - ) - }), - )); + if let Some(error) = unwritable(key, &field(key)) { + return Err(error); } } @@ -497,6 +503,57 @@ fn validated_fields(submission: &Value) -> Result { }) } +/// The config file Setup would write, from credentials that are already in +/// `values` (a launch's environment), so a launch that has them is not asked +/// for them again. `None` unless all three LiveKit keys are there, which leaves +/// the asking to Setup. +/// +/// The same checks as a submission, the format's and the URL's, but not the +/// probes: those reach the network, and a value an operator exported is taken +/// as a hand-written config file would be, and refused by the launch the same +/// way if it is wrong. +pub fn config_from_values( + values: &std::collections::BTreeMap, + production: bool, +) -> Result, String> { + let value = |key: &str| values.get(key).map_or("", |value| value.trim()); + if ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET"] + .iter() + .any(|key| value(key).is_empty()) + { + return Ok(None); + } + let fields = validated_fields(&json!({ + "livekitUrl": value("LIVEKIT_URL"), + "livekitApiKey": value("LIVEKIT_API_KEY"), + "livekitApiSecret": value("LIVEKIT_API_SECRET"), + "googleApiKey": value("GOOGLE_API_KEY"), + }))?; + crate::config::validate_livekit_url(&fields.livekit_url, production)?; + + // Setup's form takes one Google key, but an operator may export the + // rotation list instead, and dropping it would leave the install web-only. + let mut contents = fields.contents(); + let keys = value("GOOGLE_API_KEYS"); + if !keys.is_empty() { + if let Some(error) = unwritable("GOOGLE_API_KEYS", keys) { + return Err(error); + } + + // The rule the launch applies to the list, applied before the file + // exists: a list it refuses, once written, would refuse every later + // launch too, and the file's existence would keep Setup from coming + // back to correct it. The list alone, so an exported `GOOGLE_API_KEY` + // beside it is not reported as overridden here. + crate::config::google_api_keys(&std::collections::BTreeMap::from([( + "GOOGLE_API_KEYS".to_string(), + keys.to_string(), + )]))?; + contents.push_str(&format!("GOOGLE_API_KEYS={keys}\n")); + } + Ok(Some(contents)) +} + /// Proves the credentials before a file is written from them. /// /// `Some` is the refusal. The order is the point: the URL rule is local and @@ -590,9 +647,8 @@ async fn probe_credentials(fields: &SetupFields, production: bool) -> Option Option { // The directory is the caller's answer, and a released binary's copy of it // does not exist until the first submission. @@ -605,25 +661,7 @@ fn write_config_file(path: &Path, contents: &str) -> Option { )); } - // `create_new`, so an existing name is reported rather than followed and - // truncated. `is_cold_start` reached this page by finding no config, and - // the `is_file` it asked answers no for a symlink with nothing at the end - // of it: without `O_EXCL` a dangling one planted here sends these - // credentials wherever it points. - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - // Otherwise the umask decides, and its usual answer is world-readable. - // This file holds `LIVEKIT_API_SECRET` and `GOOGLE_API_KEY`. Windows - // has no equivalent here and inherits the folder's ACL. - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let written = options - .open(path) - .and_then(|mut file| std::io::Write::write_all(&mut file, contents.as_bytes())); - let Err(error) = written else { + let Err(error) = crate::create_owner_only(path, contents.as_bytes()) else { return None; }; let reason = if error.kind() == std::io::ErrorKind::AlreadyExists { @@ -649,23 +687,16 @@ async fn submit_setup( ) -> Response { let fields = match validated_fields(&submission) { Ok(fields) => fields, - Err(response) => return response, + Err(error) => { + return super::json_response(StatusCode::BAD_REQUEST, json!({ "error": error })); + } }; if let Some(refusal) = probe_credentials(&fields, production).await { return refusal; } - // From `pairs`, so the file holds exactly the config that was checked and - // probed above. Every value is known to carry no newline by now, which is - // what makes one line per key a faithful encoding of it. - let contents = fields - .pairs() - .iter() - .map(|(key, value)| format!("{key}={value}\n")) - .collect::(); - - if let Some(refusal) = write_config_file(&path, &contents) { + if let Some(refusal) = write_config_file(&path, &fields.contents()) { return refusal; } diff --git a/tests/cli.rs b/tests/cli.rs index dfa4a3a6..26c6aecb 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -152,36 +152,52 @@ fn run_cli_until_exit_once(args: &[&str], envs: &[(&str, &str)]) -> Option<(i32, Some((code, stdout, stderr)) } -/// One place builds the invocation, so the two runners cannot drift about which -/// environment the child inherits. The removals matter: a developer with -/// `NODE_ENV` or `SESSION_SECRET` exported would otherwise change what these -/// tests are testing. +/// One place builds the invocation, so the runners cannot drift about which +/// environment the child inherits: none of this process's, see `scrubbed`. +/// /// Runs a linked binary inside `cwd`, not the one in the target directory: /// the executable's own folder is a config search path and a place the Setup /// page writes, so a stray `codetrial.env.local` next to the real test binary /// would decide these tests. fn cli_command(args: &[&str], envs: &[(&str, &str)], cwd: &Path) -> Command { let mut command = Command::new(binary_beside(cwd)); - command + scrubbed(&mut command) .args(args) .current_dir(cwd) - .env_remove("LIVEKIT_URL") - .env_remove("LIVEKIT_API_KEY") - .env_remove("LIVEKIT_API_SECRET") - .env_remove("GOOGLE_API_KEY") - .env_remove("CODETRIAL_ROOM_PREFIX") - .env_remove("CODETRIAL_DURATION_MIN") - .env_remove("CODETRIAL_WEB_DIR") - .env_remove("CODETRIAL_WEB_ADDR") - .env_remove("CODETRIAL_TRUSTED_PROXY_HOPS") - .env_remove("NODE_ENV") - .env_remove("SESSION_SECRET") .envs(envs.iter().copied()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); command } +/// A child with none of this process's environment except what starting a +/// program needs. Clearing everything rather than listing what the server +/// reads, because a list has to learn every key the server does, and a key it +/// missed shows up only on the machine of a developer who exported it. +/// +/// `LLVM_PROFILE_FILE` and `RUST_BACKTRACE` pass so a coverage run still sees +/// the child and a panic in it still says where. +fn scrubbed(command: &mut Command) -> &mut Command { + command.env_clear(); + for key in [ + "PATH", + "HOME", + "TMPDIR", + "SystemRoot", + "windir", + "USERPROFILE", + "TEMP", + "TMP", + "LLVM_PROFILE_FILE", + "RUST_BACKTRACE", + ] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + command +} + /// An address for a child process to bind, given up the moment it is named. /// /// Binding then dropping is inherently racy: the child re-binds the port a @@ -353,9 +369,9 @@ fn spawn_server(build: impl Fn(&str) -> Command) -> (String, ServerProcess) { /// A cold-start server: the binary beside `dir`, with no config file on any /// path it searches and no credentials in the environment. /// -/// Three more variables are cleared than the credentials, because -/// `serve_setup` reads all three: `CODETRIAL_WEB_ADDR` decides where it binds, -/// `CODETRIAL_TRUSTED_PROXY_HOPS` can refuse the launch outright, and +/// The whole environment is cleared, not only the credentials, and three of +/// the keys `serve_setup` reads itself: `CODETRIAL_WEB_ADDR` decides where it +/// binds, `CODETRIAL_TRUSTED_PROXY_HOPS` can refuse the launch outright, and /// `NODE_ENV` decides what a submission has to survive. A suite that inherits /// any of them from whoever ran it tests a different program on their machine /// than in CI -- and the failure arrives as `spawn_server` panicking on a @@ -363,16 +379,9 @@ fn spawn_server(build: impl Fn(&str) -> Command) -> (String, ServerProcess) { fn spawn_cold_start(exe: &Path, dir: &Path, envs: &[(&str, String)]) -> (String, ServerProcess) { spawn_server(|addr| { let mut command = Command::new(exe); - command + scrubbed(&mut command) .args(["web", "--web-addr", addr]) .current_dir(dir) - .env_remove("LIVEKIT_URL") - .env_remove("LIVEKIT_API_KEY") - .env_remove("LIVEKIT_API_SECRET") - .env_remove("GOOGLE_API_KEY") - .env_remove("CODETRIAL_WEB_ADDR") - .env_remove("CODETRIAL_TRUSTED_PROXY_HOPS") - .env_remove("NODE_ENV") .stdout(Stdio::piped()) .stderr(Stdio::piped()); for (key, value) in envs { @@ -2621,3 +2630,271 @@ fn binary_agent_web_loads_primary_config_from_the_current_directory() { stop_child(&mut child); let _ = std::fs::remove_dir_all(cwd); } + +/// The launch a desktop app makes: everything in one folder it chose, and +/// nothing read from or written to the places a CLI launch looks. The working +/// directory here is a checkout with a config of its own, the executable's +/// folder has one too, and the environment carries LiveKit credentials, so a +/// launch that read any of them would be configured rather than serving Setup, +/// and one that wrote by the CLI's rule would put the file in the checkout. +#[test] +fn a_data_dir_launch_reads_and_writes_only_its_data_dir() { + let work = temp_path("data-dir-work"); + let home = exe_temp_path("data-dir-exe"); + let data = temp_path("data-dir-data"); + for dir in [&work.join("config"), &home, &data] { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write( + work.join("config").join("codetrial.env.example"), + "# keys\n", + ) + .unwrap(); + let checkout_config = + "LIVEKIT_URL=wss://checkout.example\nLIVEKIT_API_KEY=k\nLIVEKIT_API_SECRET=s\n"; + std::fs::write( + work.join("config").join("codetrial.env.local"), + checkout_config, + ) + .unwrap(); + std::fs::write(home.join("codetrial.env.local"), checkout_config).unwrap(); + let exe = binary_beside(&home); + + let livekit_addr = spawn_livekit_room_service("key", "secret"); + let data_arg = data.to_str().unwrap().to_string(); + let (addr, _server) = spawn_server(|addr| { + let mut command = Command::new(&exe); + scrubbed(&mut command) + .args(["web", "--web-addr", addr, "--data-dir", &data_arg]) + .current_dir(&work) + // Short of the three keys Setup needs, so the environment has + // nothing to offer this launch and must not decide anything. + .env("LIVEKIT_URL", "wss://environment.example") + .env("LIVEKIT_API_KEY", "environment-key") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + }); + + let page = http_request( + &addr, + &format!("GET / HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"), + ); + let body = format!( + r#"{{"livekitUrl":"http://{livekit_addr}","livekitApiKey":"key","livekitApiSecret":"secret","googleApiKey":""}}"# + ); + let submission = http_request( + &addr, + &format!( + "POST /api/setup HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ), + ); + + let written = std::fs::read_to_string(data.join("codetrial.env.local")).unwrap_or_default(); + + // Made by the launch that continues after Setup, which has no + // `SESSION_SECRET` from the file it was just given. + let deadline = Instant::now() + Duration::from_secs(10); + while !data.join("session-secret").exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(50)); + } + let secret = data.join("session-secret").exists(); + let checkout_after = + std::fs::read_to_string(work.join("config").join("codetrial.env.local")).unwrap(); + let beside_exe = home.join("config").exists(); + + // Stopped first: Windows will not remove the executable a live process was + // started from, nor the folder it keeps open. + drop(_server); + for dir in [&work, &home, &data] { + let _ = std::fs::remove_dir_all(dir); + } + + assert!(page.contains(r#"name="livekitUrl""#), "{page}"); + assert!(submission.starts_with("HTTP/1.1 200 OK"), "{submission}"); + assert!( + written.contains(&format!("LIVEKIT_URL=http://{livekit_addr}")), + "{written}" + ); + assert!(secret, "the session key belongs in the data directory"); + assert_eq!(checkout_after, checkout_config); + assert!(!beside_exe, "nothing belongs beside the executable"); +} + +/// A first launch from a shell that already exports what Setup asks for is not +/// asked: the folder's config is written from those keys, owner-only, and the +/// full app serves. Nothing else from the environment goes in, so the file is +/// still all a later launch, started with nothing exported, needs. +#[test] +fn a_first_data_dir_launch_writes_the_exported_setup_keys() { + let home = exe_temp_path("data-dir-exported-exe"); + let data = temp_path("data-dir-exported-data"); + std::fs::create_dir_all(&home).unwrap(); + let exe = binary_beside(&home); + + let livekit_addr = spawn_livekit_room_service("key", "secret"); + let livekit_url = format!("http://{livekit_addr}"); + let data_arg = data.to_str().unwrap().to_string(); + let (addr, server) = spawn_server(|addr| { + let mut command = Command::new(&exe); + scrubbed(&mut command) + .args(["web", "--web-addr", addr, "--data-dir", &data_arg]) + .current_dir(&home) + .env("LIVEKIT_URL", &livekit_url) + .env("LIVEKIT_API_KEY", "key") + .env("LIVEKIT_API_SECRET", "secret") + .env("CODETRIAL_ROOM_PREFIX", "from-the-shell") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + }); + let page = http_request( + &addr, + &format!("GET / HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"), + ); + let written = std::fs::read_to_string(data.join("codetrial.env.local")).unwrap_or_default(); + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(data.join("codetrial.env.local")) + .map(|meta| meta.permissions().mode() & 0o777) + .unwrap_or(0) + }; + drop(server); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&data); + + assert!(page.starts_with("HTTP/1.1 200 OK"), "{page}"); + assert!( + !page.contains(r#"name="livekitUrl""#), + "Setup was served: {page}" + ); + assert_eq!( + written, + format!("LIVEKIT_URL={livekit_url}\nLIVEKIT_API_KEY=key\nLIVEKIT_API_SECRET=secret\n") + ); + #[cfg(unix)] + assert_eq!(mode, 0o600, "{mode:o}"); +} + +/// The shell that started the first launch may export the same keys again on +/// the next one. The folder already has its config then, and the launch has to +/// serve from it and leave it as it was, not fail on writing it a second time. +#[test] +fn a_relaunch_with_the_keys_still_exported_keeps_the_config_it_has() { + let home = exe_temp_path("data-dir-relaunch-exe"); + let data = temp_path("data-dir-relaunch-data"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&data).unwrap(); + let exe = binary_beside(&home); + + let livekit_addr = spawn_livekit_room_service("key", "secret"); + let config = format!( + "LIVEKIT_URL=http://{livekit_addr}\nLIVEKIT_API_KEY=key\nLIVEKIT_API_SECRET=secret\n" + ); + std::fs::write(data.join("codetrial.env.local"), &config).unwrap(); + let data_arg = data.to_str().unwrap().to_string(); + let (addr, server) = spawn_server(|addr| { + let mut command = Command::new(&exe); + scrubbed(&mut command) + .args(["web", "--web-addr", addr, "--data-dir", &data_arg]) + .current_dir(&home) + .env("LIVEKIT_URL", "http://exported.invalid") + .env("LIVEKIT_API_KEY", "other-key") + .env("LIVEKIT_API_SECRET", "other-secret") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + }); + let page = http_request( + &addr, + &format!("GET / HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"), + ); + let after = std::fs::read_to_string(data.join("codetrial.env.local")).unwrap_or_default(); + drop(server); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&data); + + assert!(page.starts_with("HTTP/1.1 200 OK"), "{page}"); + assert!( + !page.contains(r#"name="livekitUrl""#), + "Setup was served: {page}" + ); + assert_eq!(after, config); +} +/// The failure note a double-clicked binary leaves beside itself goes in the +/// data directory instead: an installed app's executable folder is read-only +/// or inside a bundle. +#[test] +fn a_data_dir_launch_logs_its_failure_in_the_data_dir() { + let home = exe_temp_path("data-dir-log-exe"); + let data = temp_path("data-dir-log-data"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&data).unwrap(); + std::fs::write( + data.join("codetrial.env.local"), + "LIVEKIT_URL=wss://example.livekit.cloud\n", + ) + .unwrap(); + let exe = binary_beside(&home); + + let output = scrubbed(&mut Command::new(&exe)) + .args(["web", "--data-dir", data.to_str().unwrap()]) + .current_dir(&home) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("codetrial should exit"); + let log = std::fs::read_to_string(data.join("codetrial-error.log")).unwrap_or_default(); + let beside_exe = home.join("codetrial-error.log").exists(); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&data); + + assert_eq!(output.status.code(), Some(1)); + assert!( + log.contains("missing required LiveKit credentials"), + "{log}" + ); + assert!(!beside_exe, "the log belongs in the data directory"); +} + +/// A first launch into a folder that does not exist yet can fail before +/// anything creates it, and the log of why is written there all the same: the +/// folder is where its owner will look. +#[test] +fn a_first_data_dir_launch_that_fails_early_creates_its_folder_for_the_log() { + let data = temp_path("data-dir-missing-data"); + + // Bounded: a launch that fails to refuse serves Setup and never exits, and + // an unbounded run would hang the suite instead of failing it. + let (code, _, _) = run_cli_until_exit( + &["web", "--data-dir", data.to_str().unwrap()], + &[ + ("LIVEKIT_URL", "not a url"), + ("LIVEKIT_API_KEY", "key"), + ("LIVEKIT_API_SECRET", "secret"), + ], + ); + let log = std::fs::read_to_string(data.join("codetrial-error.log")).unwrap_or_default(); + let config = data.join("codetrial.env.local").exists(); + let _ = std::fs::remove_dir_all(&data); + + assert_eq!(code, 1); + assert!( + log.contains("the exported Setup keys cannot be written to a config"), + "{log}" + ); + assert!(!config, "a refused export writes nothing"); +} + +#[test] +fn binary_refuses_a_config_file_and_a_data_dir_together() { + let (code, _, stderr) = run_cli_args(&["web", "--config", "a.env", "--data-dir", "b"]); + assert_eq!(code, 2); + assert!( + stderr.contains("--config and --data-dir cannot be combined"), + "{stderr}" + ); +} diff --git a/tests/unit/bin.rs b/tests/unit/bin.rs index 6b7bcc1c..742a8cdd 100644 --- a/tests/unit/bin.rs +++ b/tests/unit/bin.rs @@ -607,3 +607,123 @@ fn an_allowed_hosts_list_with_no_host_is_refused_not_read_as_off() { let argv = ["web", "--allowed-hosts", " "].map(String::from); assert!(super::parse_agent_args(&argv).is_err()); } + +/// Every path a launch derives, from the one folder `--data-dir` names. The +/// CLI's own answers come from the working directory and the executable's +/// folder, which a desktop launcher does not choose, so none of them may leak +/// through. +#[test] +fn a_data_dir_decides_every_path_a_launch_reads_or_writes() { + // A folder nothing has made, so the missing config below is this test's + // doing and not whatever the host happens to keep at a fixed path. + let data = std::env::temp_dir().join(format!( + "codetrial-bin-data-dir-{}-{}", + std::process::id(), + codetrial::current_epoch_millis() + )); + let data = data.as_path(); + let options = super::CliOptions { + data_dir: Some(data.to_path_buf()), + ..Default::default() + }; + assert_eq!(super::config_dir(&options), data); + assert_eq!( + super::cold_start_config_path(&options), + data.join("codetrial.env.local") + ); + assert_eq!( + super::web_error_log_path(&options), + data.join("codetrial-error.log") + ); + assert_eq!( + super::account_db_path(&values(&[]), &options), + data.join("codetrial.db") + ); + let missing = super::primary_config_path(&options).unwrap_err(); + assert!( + missing.contains(&data.join("codetrial.env.local").display().to_string()), + "{missing}" + ); + + let pool = codetrial::config::ProviderPool { + providers: vec![provider(codetrial::config::PRIMARY_PROVIDER_ID)], + }; + let served = |values: &super::BTreeMap| { + super::web_server_config(values, pool.clone(), false, &options) + .expect("a plain local config should be accepted") + .web_dir + }; + assert_eq!(served(&values(&[])), data.join("web")); + assert_eq!( + served(&values(&[("CODETRIAL_WEB_DIR", "/elsewhere")])), + std::path::Path::new("/elsewhere") + ); +} + +/// Made once and kept, so a relaunch keeps everyone signed in, and owner-only +/// because any program on the machine can reach a loopback port. +#[test] +fn a_data_dir_session_key_is_made_once_and_kept() { + let dir = std::env::temp_dir().join(format!( + "codetrial-bin-secret-{}-{}", + std::process::id(), + codetrial::current_epoch_millis() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(super::SESSION_SECRET_FILE); + + let first = super::data_dir_session_secret(&path).unwrap(); + assert!(first.len() >= 32, "{first}"); + assert_ne!(first, super::DEFAULT_SESSION_SECRET); + assert_eq!(super::data_dir_session_secret(&path).unwrap(), first); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "{mode:o}"); + } + + // A key of every character this program writes, `-` and `_` among them, is + // kept as it is. + let written = "ab-CD_01".repeat(5); + std::fs::write(&path, &written).unwrap(); + assert_eq!(super::data_dir_session_secret(&path).unwrap(), written); + + // Refused, and left as it was, rather than replaced. + for damaged in ["", "short", "codetrial-local-session", &"=".repeat(40)] { + std::fs::write(&path, damaged).unwrap(); + let error = super::data_dir_session_secret(&path).unwrap_err(); + assert!(error.contains("not a session key"), "{error}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), damaged); + } + + // Unreadable is not missing: a key is made only where there is none. + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + let error = super::data_dir_session_secret(&path).unwrap_err(); + assert!(error.starts_with("cannot read"), "{error}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The flag a launcher passes, since a `--data-dir` launch reads no +/// configuration from its environment, and the environment it would have read. +#[test] +fn a_data_dir_launch_takes_its_settings_from_flags_not_the_environment() { + // Parsed from the command line the desktop app passes, so the flags are + // proved to reach the options as well as to be read from them. + let argv = [ + "web", + "--data-dir", + "/data", + "--allowed-hosts", + "127.0.0.1:3101", + ] + .map(String::from); + let (_, options) = super::parse_agent_args(&argv).unwrap(); + assert_eq!(options.data_dir, Some(std::path::PathBuf::from("/data"))); + assert!(super::environment(&options).is_empty()); + assert_eq!( + super::allowed_hosts(&super::environment_and_flags(&options)).unwrap(), + ["127.0.0.1:3101"] + ); +} diff --git a/tests/unit/web/setup.rs b/tests/unit/web/setup.rs index 8914f357..c5d07d91 100644 --- a/tests/unit/web/setup.rs +++ b/tests/unit/web/setup.rs @@ -176,3 +176,82 @@ impl Drop for AccountsScratch { std::fs::remove_dir_all(&self.dir).ok(); } } + +fn values(pairs: &[(&str, &str)]) -> std::collections::BTreeMap { + pairs + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} + +/// Exported credentials become the file a submission of them would have, so a +/// launch that has them is not asked again; anything short of the LiveKit three +/// is left for Setup to ask. +#[test] +fn exported_credentials_become_the_file_setup_would_write() { + let livekit = [ + ("LIVEKIT_URL", " wss://example.livekit.cloud "), + ("LIVEKIT_API_KEY", "key"), + ("LIVEKIT_API_SECRET", "secret"), + ]; + assert_eq!( + super::config_from_values(&values(&livekit), false) + .unwrap() + .unwrap(), + "LIVEKIT_URL=wss://example.livekit.cloud\nLIVEKIT_API_KEY=key\nLIVEKIT_API_SECRET=secret\n" + ); + + // The Google keys ride along when there are any, and nothing else does: the + // whole file is compared, so no other exported key can slip in. + let mut full = livekit.to_vec(); + full.extend([ + ("GOOGLE_API_KEY", "google"), + ("GOOGLE_API_KEYS", "one,two"), + ("NODE_ENV", "production"), + ("CODETRIAL_DB_PATH", "/elsewhere.db"), + ]); + assert_eq!( + super::config_from_values(&values(&full), false) + .unwrap() + .unwrap(), + "LIVEKIT_URL=wss://example.livekit.cloud\nLIVEKIT_API_KEY=key\nLIVEKIT_API_SECRET=secret\n\ + GOOGLE_API_KEY=google\nGOOGLE_API_KEYS=one,two\n" + ); + + for missing in 0..livekit.len() { + let mut partial = livekit.to_vec(); + partial[missing].1 = " "; + assert_eq!( + super::config_from_values(&values(&partial), false), + Ok(None) + ); + } +} + +/// Refused rather than written, for the reasons a submission is: the file +/// cannot carry the value, or the launch reading it back would refuse the URL +/// or the Gemini key list. +#[test] +fn exported_credentials_the_file_cannot_hold_are_refused() { + for (key, value) in [ + ("LIVEKIT_API_SECRET", "secret\nSESSION_SECRET=chosen"), + ("LIVEKIT_API_KEY", "\"key\""), + ("LIVEKIT_URL", "ftp://example.livekit.cloud"), + ("GOOGLE_API_KEYS", "one\nSESSION_SECRET=chosen"), + ("GOOGLE_API_KEYS", "one,"), + ("GOOGLE_API_KEYS", "one,,two"), + ("GOOGLE_API_KEYS", "one,one"), + ] { + let mut pairs = vec![ + ("LIVEKIT_URL", "wss://example.livekit.cloud"), + ("LIVEKIT_API_KEY", "key"), + ("LIVEKIT_API_SECRET", "secret"), + ]; + pairs.retain(|(name, _)| *name != key); + pairs.push((key, value)); + assert!( + super::config_from_values(&values(&pairs), false).is_err(), + "{key}={value:?}" + ); + } +} From 3c1824dfaada8eefaffbe4f37e29331e0a52735c Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sun, 18 Oct 2026 04:04:09 +0800 Subject: [PATCH 4/8] Exit the server when its parent's pipe closes A program that starts the server as its child keeps it on a fixed port, and when that program crashes the server outlives it and holds the port, so the next launch cannot bind it. Parent-death signals and job objects differ on every platform; a pipe is closed by the kernel when its writer dies, however it dies, everywhere. With CODETRIAL_EXIT_WITH_STDIN set, the server exits once its standard input reaches end of file. It is read from the process environment even under --data-dir, because it describes the pipe the launcher handed over rather than configuring the server, and it has to hold while Setup serves. The exit does not print with eprintln, which would panic on the dead parent's stderr and end the watching thread instead. --- README.md | 1 + src/config.rs | 5 +++++ src/main.rs | 30 ++++++++++++++++++++++++++++++ tests/cli.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/config.rs | 13 +++++++++++++ 5 files changed, 97 insertions(+) diff --git a/README.md b/README.md index c6c995e9..b7193c06 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,7 @@ The common ones: | `CODETRIAL_COMPILER_EXPLORER_ENABLED` | `true` | Enable remote C, C++, and Java runs | | `CODETRIAL_MAX_CONCURRENT_INTERVIEWS` | `16` | Interviews one `web` process hosts agents for | | `CODETRIAL_ALLOWED_HOSTS` | empty | Comma-separated `Host` values the server answers; any other is refused with 421. Empty answers every name, but a value that names no host is refused at startup. `--allowed-hosts` sets it from the command line | +| `CODETRIAL_EXIT_WITH_STDIN` | unset | For a program that starts the server as its child: exit when standard input reaches end of file, so the server does not outlive a launcher that crashed. Read from the process environment even under `--data-dir` | Two more matter in production. `SESSION_SECRET` signs the session cookie, and its built-in default is a string published in this repository, so the server diff --git a/src/config.rs b/src/config.rs index afbfb5ed..2e0ce630 100644 --- a/src/config.rs +++ b/src/config.rs @@ -984,6 +984,11 @@ pub fn guest_mode_enabled(value: Option<&str>) -> bool { parse_bool(value, DEFAULT_GUEST_MODE_ENABLED) } +/// `CODETRIAL_EXIT_WITH_STDIN`: off unless a launcher asks for it. +pub fn exit_with_stdin(value: Option<&str>) -> bool { + parse_bool(value, false) +} + pub fn gemini_candidate_video_enabled(value: Option<&str>) -> bool { parse_bool(value, DEFAULT_GEMINI_CANDIDATE_VIDEO_ENABLED) } diff --git a/src/main.rs b/src/main.rs index 796c5391..7d15021e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::io::Write as _; use std::path::PathBuf; use std::sync::Arc; @@ -339,6 +340,7 @@ fn run_web(options: CliOptions) -> Result<(), String> { // Why that socket is carried here rather than rebound: `serve_setup`. Read // once, so the question and the page it opens answer from the same map. let before_config = environment_and_flags(&options); + exit_when_stdin_closes(); data_dir_config_from_environment(&options, is_production(&before_config))?; let handed_over = if is_cold_start(&options, &before_config) { Some(serve_setup(&before_config, &options)?) @@ -710,6 +712,34 @@ fn web_error_log_path(options: &CliOptions) -> std::path::PathBuf { } } +/// Exits once standard input reaches end of file, when the process environment +/// sets `CODETRIAL_EXIT_WITH_STDIN`. +/// +/// For a launcher that runs this as its child. A pipe is closed by the kernel +/// when its writer dies however it dies, so this is the one way a child learns +/// its parent is gone on every platform alike: a crashed desktop app otherwise +/// leaves this server holding its fixed port, and the next launch fails to +/// bind it. From the process environment even for a `--data-dir` launch, which +/// reads no configuration there, because this is not configuration: it is the +/// launcher describing the pipe it handed over, and it has to hold while Setup +/// serves, before any config file exists. +fn exit_when_stdin_closes() { + if !codetrial::config::exit_with_stdin( + std::env::var("CODETRIAL_EXIT_WITH_STDIN").ok().as_deref(), + ) { + return; + } + std::thread::spawn(|| { + let _ = std::io::copy(&mut std::io::stdin().lock(), &mut std::io::sink()); + + // Not `eprintln!`, which panics when stderr is a pipe nobody reads any + // more: when the parent died, it held the other end of that one too, + // and the panic would end this thread instead of the process. + let _ = writeln!(std::io::stderr(), "standard input closed: exiting"); + std::process::exit(0); + }); +} + fn run_livekit(config: AgentConfig, room_name: &str) -> Result<(), String> { let config = select_provider(config, room_name)?; let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should start"); diff --git a/tests/cli.rs b/tests/cli.rs index 26c6aecb..1ae006a5 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2898,3 +2898,51 @@ fn binary_refuses_a_config_file_and_a_data_dir_together() { "{stderr}" ); } + +/// A launcher's crash closes the pipe it held, and the server it started must +/// not outlive it on the launcher's fixed port. Checked while Setup serves, +/// because that is before any config exists and the first thing a new install +/// runs. +#[test] +fn a_server_told_to_follow_its_stdin_exits_when_it_closes() { + let home = exe_temp_path("stdin-exe"); + let data = temp_path("stdin-data"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&data).unwrap(); + let exe = binary_beside(&home); + let data_arg = data.to_str().unwrap().to_string(); + + let (_addr, mut server) = spawn_server(|addr| { + let mut command = Command::new(&exe); + scrubbed(&mut command) + .args(["web", "--web-addr", addr, "--data-dir", &data_arg]) + .current_dir(&home) + .env("CODETRIAL_EXIT_WITH_STDIN", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + }); + + // All three ends, as a parent's death closes them. With stdout and stderr + // still read here, a server that wrote to a dead pipe on its way out would + // pass this and then outlive a real parent. + drop(server.stdout.take()); + drop(server.stderr.take()); + drop(server.stdin.take()); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = server.try_wait().expect("child status should read") { + break Some(status); + } + if Instant::now() >= deadline { + break None; + } + thread::sleep(Duration::from_millis(25)); + }; + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&data); + + let status = status.expect("the server should exit once its stdin closes"); + assert!(status.success(), "{status}"); +} diff --git a/tests/config.rs b/tests/config.rs index 332f2b44..d3e10090 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -261,6 +261,19 @@ fn guest_mode_is_disabled_by_default_and_fails_closed() { } } +/// Off unless asked for: a server whose stdin is `/dev/null`, which is every +/// service manager's default, would otherwise exit the moment it started. +#[test] +fn exiting_with_stdin_is_only_on_when_a_launcher_asks() { + let exits = codetrial::config::exit_with_stdin; + for off in [None, Some(""), Some("0"), Some("false"), Some("maybe")] { + assert!(!exits(off), "{off:?} should leave it off"); + } + for on in ["1", "true", "yes"] { + assert!(exits(Some(on)), "{on} should turn it on"); + } +} + /// Asserted on the parser rather than through a config struct, because the /// struct field it used to be read through was only ever consumed by `serve`. /// From cf5f0848f5cd825aebd390b6a1f8f50bdfec30a4 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sun, 18 Oct 2026 04:04:31 +0800 Subject: [PATCH 5/8] Ship CodeTrial as a desktop app Running CodeTrial means starting a server from a terminal and opening its address in a browser. desktop/ is a Tauri 2 app for macOS, Windows and Linux that starts the codetrial server as its child, in its own data folder on a fixed loopback port that only it may name, and shows the page in its own window. The server stays a separate program, so the CLI's startup is unchanged and the child is the binary its tests cover. The page gets no native access: only the server's origin and the app's starting page load in the window, the camera and microphone go to the server's page alone, and a close asks the page's leave-page guard first. Where WebKitGTK is built without WebRTC, which the interview's call needs, the interview opens in the default browser instead. The starting page takes the lobby's palette, accent and radii, copied since it loads before any server can serve them. The crate builds into target/desktop, and the gate builds and tests it where WebKitGTK is installed. CI's Rust lane installs it on every run, since the crate's tests read the server's side of the launch from src/main.rs and a gate that skips there fails the run. CI's dependency audit reads the crate's lockfile beside the root's. --- .../actions/restore-cargo-cache/action.yml | 6 + .github/dependabot.yml | 12 + .github/workflows/check.yml | 46 + Cargo.toml | 5 +- README.md | 8 +- desktop/.cargo/config.toml | 10 + desktop/.gitignore | 3 + desktop/Cargo.lock | 4989 +++++++++++++++++ desktop/Cargo.toml | 42 + desktop/Entitlements.plist | 10 + desktop/Info.plist | 10 + desktop/build.rs | 8 + desktop/icons/128x128.png | Bin 0 -> 2266 bytes desktop/icons/128x128@2x.png | Bin 0 -> 4372 bytes desktop/icons/32x32.png | Bin 0 -> 632 bytes desktop/icons/icon.icns | Bin 0 -> 55063 bytes desktop/icons/icon.ico | Bin 0 -> 10646 bytes desktop/icons/icon.png | Bin 0 -> 8791 bytes desktop/icons/icon.svg | 6 + desktop/src/main.rs | 512 ++ desktop/src/navigation.rs | 97 + desktop/src/server.rs | 262 + desktop/tauri.bundle.json | 6 + desktop/tauri.conf.json | 41 + desktop/tests/unit/navigation.rs | 133 + desktop/tests/unit/server.rs | 166 + desktop/ui/index.html | 38 + desktop/ui/start.css | 137 + desktop/ui/start.js | 26 + docs/desktop.md | 230 + docs/development.md | 7 +- scripts/audit.py | 74 +- scripts/indent.sh | 46 +- scripts/test.sh | 24 +- tests/browser/setup-page.test.js | 48 + tests/cli.rs | 80 + tests/test_audit.py | 64 +- tests/test_test_gate.py | 8 +- web/before-unload.js | 5 + 39 files changed, 7119 insertions(+), 40 deletions(-) create mode 100644 desktop/.cargo/config.toml create mode 100644 desktop/.gitignore create mode 100644 desktop/Cargo.lock create mode 100644 desktop/Cargo.toml create mode 100644 desktop/Entitlements.plist create mode 100644 desktop/Info.plist create mode 100644 desktop/build.rs create mode 100644 desktop/icons/128x128.png create mode 100644 desktop/icons/128x128@2x.png create mode 100644 desktop/icons/32x32.png create mode 100644 desktop/icons/icon.icns create mode 100644 desktop/icons/icon.ico create mode 100644 desktop/icons/icon.png create mode 100644 desktop/icons/icon.svg create mode 100644 desktop/src/main.rs create mode 100644 desktop/src/navigation.rs create mode 100644 desktop/src/server.rs create mode 100644 desktop/tauri.bundle.json create mode 100644 desktop/tauri.conf.json create mode 100644 desktop/tests/unit/navigation.rs create mode 100644 desktop/tests/unit/server.rs create mode 100644 desktop/ui/index.html create mode 100644 desktop/ui/start.css create mode 100644 desktop/ui/start.js create mode 100644 docs/desktop.md diff --git a/.github/actions/restore-cargo-cache/action.yml b/.github/actions/restore-cargo-cache/action.yml index 5d0d8357..774fbdb9 100644 --- a/.github/actions/restore-cargo-cache/action.yml +++ b/.github/actions/restore-cargo-cache/action.yml @@ -9,6 +9,8 @@ description: Restore the build tree the check workflow's Rust lane saves. # # `registry/src` is deliberately absent: it is unpacked from `registry/cache` on # demand, so caching both stores every dependency twice. +# `target/desktop` is the desktop app's build, which only the Rust lane reads +# and which it caches in an entry of its own. # # Keyed on the lockfile, so a dependency bump rebuilds and a source edit does # not, and on what compiles every object in target/: rustc, the C++ compiler @@ -25,6 +27,9 @@ outputs: key: description: The exact key, for the step that saves the tree. value: ${{ steps.key.outputs.key }} + prefix: + description: The key without the lockfile, for a cache built the same way. + value: ${{ steps.key.outputs.prefix }} cache-hit: description: Whether the exact key was restored. value: ${{ steps.restore.outputs.cache-hit }} @@ -59,5 +64,6 @@ runs: ~/.cargo/registry/cache ~/.cargo/git/db target + !target/desktop key: ${{ steps.key.outputs.key }} restore-keys: ${{ steps.key.outputs.prefix }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6e194cf6..2b94b670 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,6 +38,18 @@ updates: directory: / schedule: interval: weekly + # The desktop app's own lockfile. Tauri and its plugins are pinned exactly in + # desktop/Cargo.toml, so they move only when that file is edited on + # purpose; this keeps everything beneath them current, in one group so the + # GTK and WebView crates that have to agree move together. + - package-ecosystem: cargo + directory: /desktop + schedule: + interval: weekly + groups: + desktop: + patterns: + - "*" - package-ecosystem: npm directory: / schedule: diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9682350c..c3dcbf8d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -225,6 +225,41 @@ jobs: id: cargo-cache uses: ./.github/actions/restore-cargo-cache + # The desktop crate's cache key: the cargo cache's compilers and flags, + # under a name of its own and with its own lockfile. + - name: Compute the desktop cache key + if: matrix.lane == 'rust' + id: desktop + env: + CARGO_PREFIX: ${{ steps.cargo-cache.outputs.prefix }} + INPUTS: ${{ hashFiles('desktop/Cargo.lock') }} + run: | + prefix="desktop-${CARGO_PREFIX#cargo-}" + echo "prefix=$prefix" >>"$GITHUB_OUTPUT" + echo "key=$prefix$INPUTS" >>"$GITHUB_OUTPUT" + + # What desktop/ links against. Installed on every run of this lane, not + # only when a pull request touches desktop/: the `desktop` gate skips + # without them, and under CI a gate that skips fails the run, so an + # unrelated pull request would fail here. + - name: Install the desktop app's build dependencies + if: matrix.lane == 'rust' + uses: ./.github/actions/apt-install + with: + packages: libwebkit2gtk-4.1-dev libxdo-dev libssl-dev librsvg2-dev + + # The desktop crate's build, in an entry of its own. The cargo cache is + # also what every mutation shard restores, and a path added to it would + # be a gigabyte of WebKit bindings each shard downloads and never reads. + - name: Restore desktop cargo cache + id: desktop-cache + if: matrix.lane == 'rust' + uses: actions/cache/restore@v6 + with: + path: target/desktop + key: ${{ steps.desktop.outputs.key }} + restore-keys: ${{ steps.desktop.outputs.prefix }} + # Every lane fetches and verifies these bytes; only the portable lane # saves them, below, so three lanes do not race to write one key. - name: Restore vendored bytes @@ -380,8 +415,19 @@ jobs: ~/.cargo/registry/cache ~/.cargo/git/db target + !target/desktop key: ${{ steps.cargo-cache.outputs.key }} + - name: Save desktop cargo cache + if: >- + ${{ !cancelled() && github.event_name != 'merge_group' + && matrix.lane == 'rust' + && steps.desktop-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: target/desktop + key: ${{ steps.desktop.outputs.key }} + # The one status: every gate lane and audit passed, and on a PR the mutation # plan and every shard did too. Planning only runs on pull requests, and the # shards only after a plan that found mutants, so a skipped shard job after a diff --git a/Cargo.toml b/Cargo.toml index 9b24aa21..c91c8250 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,11 +98,12 @@ test = true # scripts/hook-check holds every `cargo build` to the git hooks being in # effect; its build.rs says why it is a member of its own. `vendor/` holds path -# dependencies, not members. +# dependencies, not members, and `desktop/` is a crate of its own with its own +# lockfile that the root's builds must not need WebKitGTK to compile. [workspace] members = ["scripts/hook-check"] default-members = [".", "scripts/hook-check"] -exclude = ["vendor"] +exclude = ["vendor", "desktop"] # Release archives distribute the executable, not a debugging target. Cargo # already omits debug information in this profile; omit linker symbols too. diff --git a/README.md b/README.md index b7193c06..f307fae5 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ rather than guessing. | Phase | Required | Notes | |---|---|---| | Build time | Rust 1.98 or newer (rustup selects the release `rust-toolchain.toml` pins), `curl` or `wget`, and `sha256sum` or `shasum`; Linux also needs Clang 21+ and GLib development headers | Cargo resolves application crates from `Cargo.lock`. `make build` fetches the checksum-pinned browser assets on the first build. | -| Test time | Build-time tools, Node.js 18+, and Python 3.9+ | Node runs browser and fixture checks; Python verifies generated problem-bank files. `npm ci` adds pinned Prettier, ESLint and Playwright for the full local browser gate. `ruff`, `shellcheck`, `shfmt`, `commentflow`, `actionlint`, `cargo-audit`, and `cargo-deny` are optional: each gate reports that it skipped rather than failing without them; locally the `actionlint` lane also takes its container image. CI installs these tools and enforces its test lanes whatever a checkout can run. The independent [dependency security check](docs/development.md#dependency-security-checks) blocks newly introduced vulnerabilities on PRs and runs full audits on main. | +| Test time | Build-time tools, Node.js 18+, and Python 3.9+ | Node runs browser and fixture checks; Python verifies generated problem-bank files. `npm ci` adds pinned Prettier, ESLint and Playwright for the full local browser gate. `ruff`, `shellcheck`, `shfmt`, `commentflow`, `actionlint`, `cargo-audit`, `cargo-deny`, and on Linux `libwebkit2gtk-4.1-dev` (the desktop app's clippy and tests) are optional: each gate reports that it skipped rather than failing without them; locally the `actionlint` lane also takes its container image. CI installs these tools and enforces its test lanes whatever a checkout can run. The independent [dependency security check](docs/development.md#dependency-security-checks) blocks newly introduced vulnerabilities on PRs and runs full audits on main. | | Runtime | The compiled `codetrial` binary and a config file | No Node.js, Python, or `node_modules` is required. Rust dependencies are compiled into the binary; browser dependencies are vendored, checksum-pinned, and embedded, so a `web/` directory is optional and only overrides what is already inside. | On Linux x86_64, `make` downloads the checksum-pinned WebRTC compiler into @@ -99,7 +99,9 @@ deploy for other people. See [docs/install.md](docs/install.md). Prebuilt binaries for Linux, macOS, and Windows are published for every build of `main`. What they carry, the config file each needs beside it, and the platform -notes are in [docs/install.md](docs/install.md). From a checkout instead: +notes are in [docs/install.md](docs/install.md). A desktop app that starts the +server itself and shows it in its own window is described in +[docs/desktop.md](docs/desktop.md). From a checkout instead: ```bash INTERVIEW_ROOM_NAME=interview-local make web @@ -375,6 +377,7 @@ recorded rather than left implicit. See | [Contributing](CONTRIBUTING.md) | Reporting a bug, the commit and title rules, opening a pull request | | [Development](docs/development.md) | The gate, formatters, hooks, generated files, releases | | [Installing a binary](docs/install.md) | Published binaries, platform notes, the config beside them | +| [Desktop app](docs/desktop.md) | The macOS, Windows and Linux app, its data folder, and what it has not been qualified for | | [Interview length](docs/interview-length.md) | What the lobby offers and what the endpoints enforce | | [Integrity evidence](docs/integrity-evidence.md) | Response windows, and what CodeTrial declines to look for | | [Third-party notices](THIRD-PARTY-NOTICES.md) | Bundled software, licenses, checksums | @@ -396,6 +399,7 @@ recorded rather than left implicit. See ```text src/ Rust web server, interviewer, and problem bank +desktop/ Desktop app that runs the server in its own window web/ Static browser application problem-bank/ Problem and judge source of truth config/ Env file templates and per-provider credentials diff --git a/desktop/.cargo/config.toml b/desktop/.cargo/config.toml new file mode 100644 index 00000000..468e8359 --- /dev/null +++ b/desktop/.cargo/config.toml @@ -0,0 +1,10 @@ +# Under the repository's own target/, so a checkout has one place everything it +# builds lands and one directory to delete. Its own subdirectory rather than +# target/ itself: CI caches the root's build and every mutation shard restores +# that entry, which should not carry the WebKit bindings this crate builds. +# +# Read by cargo and the Tauri CLI when run from this directory. A command run +# from the repository root with --manifest-path does not see this file and +# passes --target-dir instead, as scripts/test.sh does. +[build] +target-dir = "../target/desktop" diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 00000000..9c0f2639 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,3 @@ +# Written by tauri-build and removed again by build.rs; ignored for a build +# that stops between the two. +/gen/ diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock new file mode 100644 index 00000000..2aff5f29 --- /dev/null +++ b/desktop/Cargo.lock @@ -0,0 +1,4989 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "codetrial-desktop" +version = "0.1.0" +dependencies = [ + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "webkit2gtk", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.8+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_vcruntime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "static_vcruntime", + "strsim", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f" +dependencies = [ + "base64 0.23.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.18.1", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561cd99484561bc39c954768e7b41a66cebbe1ac3d9d081c34361f740c489e2c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" +dependencies = [ + "icu_properties", + "regex", + "serde", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow 1.0.4", +] diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml new file mode 100644 index 00000000..87c70048 --- /dev/null +++ b/desktop/Cargo.toml @@ -0,0 +1,42 @@ +# The desktop app: a window around the `codetrial web` server, which it starts +# as its own child process and owns for as long as it runs. See +# docs/desktop.md for why the server is a separate program rather than linked +# in. +# +# A crate of its own with its own lockfile, outside the root manifest, because +# the root is what the Bullseye release build and every gate compile, and none +# of them should need WebKitGTK to do it. scripts/test.sh reaches this crate +# through its `desktop-*` gates. +[package] +name = "codetrial-desktop" +version = "0.1.0" +edition = "2024" +rust-version = "1.98" +publish = false + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "deny" + +# Exact, because a Tauri minor release has changed what a remote page may +# reach before, and the trust boundary in docs/desktop.md is written against +# this one. +[build-dependencies] +tauri-build = { version = "=2.7.3", features = [] } + +[dependencies] +tauri = { version = "=2.12.4", features = [] } +tauri-plugin-dialog = "=2.8.3" +tauri-plugin-opener = "=2.7.2" +tauri-plugin-single-instance = "=2.5.4" + +# The two WebKitGTK settings wry leaves off, which the interview cannot run +# without: `enable-media-stream` for the camera and microphone, `enable-webrtc` +# for the LiveKit call. The same version and feature level wry already builds. +[target.'cfg(target_os = "linux")'.dependencies] +webkit2gtk = { version = "=2.0.2", features = ["v2_40"] } + +[profile.release] +strip = "symbols" diff --git a/desktop/Entitlements.plist b/desktop/Entitlements.plist new file mode 100644 index 00000000..57ad7c7f --- /dev/null +++ b/desktop/Entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.device.camera + + com.apple.security.device.audio-input + + + diff --git a/desktop/Info.plist b/desktop/Info.plist new file mode 100644 index 00000000..180fbf7b --- /dev/null +++ b/desktop/Info.plist @@ -0,0 +1,10 @@ + + + + + NSCameraUsageDescription + The interviewer sees you through the camera during an interview, the way a video interview would. + NSMicrophoneUsageDescription + The interviewer listens to your answers through the microphone during an interview. + + diff --git a/desktop/build.rs b/desktop/build.rs new file mode 100644 index 00000000..d600b71a --- /dev/null +++ b/desktop/build.rs @@ -0,0 +1,8 @@ +fn main() { + tauri_build::build(); + + // tauri-build writes capability schemas into the crate's own `gen/`, a path + // it does not let a caller move. This app grants no capability, so nothing + // reads them, and a build should leave no output outside target/. + let _ = std::fs::remove_dir_all("gen"); +} diff --git a/desktop/icons/128x128.png b/desktop/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..c6f61cc84b8d6c9728da221b315f45253841158c GIT binary patch literal 2266 zcmV<02qpK4P) zbN2iF?m6H2zVDu!0Y*b3!zN$>FjxQ#765|xejzTqNoPM037?0-N6% zv`C-ed*f3;y3H ze5W0P=7=K`BLIeX<kY zU;)%C0MW{J^wtw-ty=)>0Q&6+@{J)3765|$+gU1M{Q*kNtfU_8&e0{rd+}(Owd$Srpp5q`l9@j_-VLBW!r73+B$v-uVpQ zFJIOc5j6DkucYm5?RgnCz4H9D?Ja(2A)GjM&Pjr=Umo6;Iv)VZU;Et38GnygDUw13 zJQtpGCSYGsAnXJt05tY&|@0OF93i5Q#fZEPF+;Ijyv5dbdrbjU~JZ{K~$dcUy` zIwI6v);5FyFmQsw9#g}wY3NQvx`8c7b11l0X3urte2YFc+nB<_tJ;4-j62>Ylj0-I^%j4zozG%OmJY}GMOz!RX=v3tlz;Ai+d{0_y4fPL1@AnLA$yb?g1v@T_4@9k&)fx_dr zYJLyDgWm-U8N@j85HAE^JFSbuz6$x1MS#aNLK->aVseK%r;l@f630-dk&_4+8yCRZ zXIE-x_9)56`Fy%LzZUQq%GE)fG&0)GdjaS;H6}89kH#RM;#eSvVkY4b^39>ZcLy7V zPIaz{fXIcbO;(Pjh=EX6Hd_|T>&8IPcHRph zii&Bjg{hZ8pAXGXLj*9#^ zeMj3Q;rM~h^8#8|bPGsnI=H&dM{l3f;d80F`Qn*_fpc*H7fPy;@L$4g^hzuFP6vYU z57w=wb@>jPxa8~FAytt;UFD-;w{CWKD;DgVF8Q`=9GkeB78<&`Ipa!6wazYEE>zQ| zrL;ZcQeC}nHLVLfb73EJtDCWW9Qe+l`m1Se@63g5&$fMB63f|CrGtGcsdgcjk;;02 zst9y5h)Ie-mwen{%0=>>EeMH|K}>3At~hDks65<3@|~^-ic?Z;dS|YkueKL4c*M1=^e`&swh-9jf}f{Q&!aCxpa0PPin`qhN=pVlR=~$ z{(Z>oLE3i^`AL#$ za{*cCmEPrbHnX#y|8?XDOeKS)vPNYQS&>Z*t`_(eBaSF*0`JD?rSeG2l8zSnb~#(z zsjGi?_*1E$e4jv=IXE;ZgF|cB^iGgJBz?3_NVFax2LCQ5mwM>! zpDGPFRTCI>CCmjK%j0T;>Jd8u15TcuDBO+dgcXc9sRt0dE5DGGn z(987%F$y{{r7N>~CWuc&0QZQY0=QOfpLpe26jQ2q!-*2EGFI}KB;ASt7{E7%FjxQ# o765|69SjqZ85_HXFP8o47+??H8hyLzvw zOP$L~3C43L|0LBl@qESjU!Gg8-X#zIIZU~ayS+orpdap%FEHr?Fz{Dlt1Q6%hZumd z7=iz8lm)O-D9(%$lZoM6!Sre#r zvnX`@wTyRsTHHs=EcOdXc8SGwnRyP6EHkZjaPOT}uk_gQDGfyX4GBQ5rc$5$o5O4t3dqD@n^Qbt8-50(h^5)>ZlAi(#i+EXm2Hs z-jg`9S(97+yME0OZ8#|fJG%o-4CPyWsSoaQ2Z(Jq52 z!e7X(X})A6-{JC$jHD)qRmZ!3HWF&h-8QUEC17!>(NT`=M~C2;T~}ZrxXtF_X=dhX zpdkk9w1|*t0Pj6<{FjAwoPr^N>XO$EneC(E`CX1Nbz2`O@y1DF}-2cI3P6+{N6LQdW))OXXq*PcrO{T|%g~E^9JEP1{ypUh_QxulR5aA} zJ>>$fHsvgv*;OpQ@EoM=dxRqi0SaaKx|i-`wS!r|14!B!k6@J~;DiG7%m+@XQ?u$^ zSy?vC#~@Nc>V8jRxzWyt22An%uiR8DY7P9mEK-q{CA8 z{}s^VbfTH6wR6xX2j;7L(|>xLQkmgvjfbRg|MN_-Z**DF`JN_eU*?MFqy4otSU@-e zRJHVr?7QzV-W=?|8g6vGs-G*`(-aVn=I@A>v#U{`QC9A;NV+2RAmY()_QU1YN#B&S z${KS6bj_NbE4Be^q5|8Ma`oG_Vf$a3-k4WWa=l$Sg>aPSG7q$)dPJ^@R{t_xiNKFS z!XccDN{;q*Hl5ir)1HcfrSG9^2+$l+V@$huRsm8v|7k(3<_LxeU>&Y}lS5YPD$wQC z%Nf}X`kLfm_N!~j6Vf;kr6oeo3SE_&ws|lXQb1jjSsdGTs+N9<=7!pFGBDrm9Odxo zn4b6ddjg-bGD{RQ&4)N656(U8grV}e(2W>G&jGcsSomJ$QGYh7pGUU5+3^=d_J?a ztKHK#^R{8aHN%!43TAIu(0lgX(#+@n%B?JOl!XD7;!Aur7Y4%nJ`i&i|9v0{Arl=4 zLdPMaL=Tq-L4&F6O1Wv8oCVAei2=>*O(un&d6RQA>`mC_z27t>3xYflIC@Yj=D$BE zldX~6{-M1ZftS<=E!Cf%(a6%!;TRMs``&35%?w1XtLf+f1mUa)R*mH|;5r)%ZkSv* zmWDcze>BlC3_mF%4@K*BDq`u5QIpmvihexEbn;Xt-Pc$g_t_qyi^Y7ERziWOCVJ~y zui6|}2%ke7n&qVG2i}1Ld(-dR5GtQ*hvCZU${pWCcso{} zSC%n81u(`+Eygz&SH&LPw>D*^%J zF0H?D2>o5L#i(b*$313DEI(7C9ukV%SBZ^<)-s1tslRr&1KS;)>y2vae+&HHjo{7X zrLnE8hUCZ-*Uyc~X!j4b|kwT;zjLsAp%r8?Xo8yxufN zlpy9SzPwGa$>Zexr;QyQY`@k^`KjH{2ngcXR+Rax2PSfV8cGWhuvzME^qmNiis?am zwIFbn9*(LR`xJXVLbxJXVMI>0lh>vT`i+EoW*L1E0qLa-)%wYghWOftskZgp)q`!` znfVC6C8cK&g(UvmfA&`meR2L^<@wyzeT_qz=Za9CZQD=5$Xc%e1o4u}u>FT~*_XARDnV3bAtp1Wls~5Wg=*B?h`O|;&{Z?|# zIB^~tBBNRKC7F36t{(0%(ZC;=@cY>mer{V==_qeE zJup+_aK*NH^aQb{Fuad6u#@o9-$hQv5sf*{(WSE0_B=YbYP+PuU}$G)b0WHRXfLC0 zrGLTAIWBsYs{9fSo(d=NVbEz0_-21sOM~i6VKbqDurLzpx$lWwPQopI%11=Y4NKCI zJL1hr;P@G+<;%i<0&G_x5M*r4+fx`kc2CECG6@JCg{+XDIyjBYg$jhO8^{43l-cvM zdZ&N+v+@Q4=V?!rgzk7Xn0h@-%uWD?|5A1tlB7zQ>90TYTGayYGBOC;T0S*7<6}DO zl3+MSl82DdeT9A(d4K@QQ;2A++!~_|9wiPbH@757K<|1ch&FJzKm4O3H5Cb9XP}?& zjVTj0C}Rb?M4AEUj3X|6av56iU9NFrVLZ6#Pb8s)kr1786Oq7hkF~c2KI**y5{zPy zwT}??a!^_W;^PM`!4gDT#Yww79#*I^OTv$bW4u|Q66!O6P@Y0|>bP4jePJ1}vrq|D zgb1~Bz}nG-MHdpxp>XY-vWr&(f=ENR=sOVBU0FeuqeNQhgYz&f6n_Ku!~Z<5bc#hv58)*;p+mQe<#5rGm&97EvNi?~<#9+SOIzPN9xuU(%-zw2Tb5r1M zW*>R604u(=_$N@0meS*Fuzq=#QdcMDJGYw}Y>H=H%X3L@lwM8@=as$2jzY6mE*Eb? zSm^FbPqa?cGym}O(1h2Kfwg8$+Mlklo@4dL0UQE( zZ2SL|HMlEwMrq4Gm7!^-s0wRc_{FXQ@bx79uL3y|R)TLc|;WOjH2$ z#RE3dkH^JqG=o2D|L~}OlZ5(ZF_MPIQE#~*!0+SU9PT9}Df?S@Xe&a33k)nKT z&0Nuh)VYb89-%;ZT}u5Rs%nCU{J?CPHffU_9pOWUNpgX8Op&_7$-vto{u+l(YTC@!>S(&vFtwIZa=K>G6E-I6d5X#?>KA=AuX&|jI2 z?(|s8{u3HT#G*-teGxf68sr!1VT0E0vCUct1hRvy>xWAhiVm;7Vpo0r z>cN@f%oD)F+~dG>f0 zT>CzKCg7koYSImkQwCbzvu_B5=tw#DtjNE4j+w6ou{D__o)+NJ$E?3A%WI%pUvIKn zk!l(eqIfE@hf|H4YrTr_N?lwsYZIj+53MRnD z_Lt3i7&dnffu(X@S@oaV=Vimu;8OO9&e-nvcdg1}ia1R;c60-a?zWK0;pI?2xV&KJ z2d|43b6POS2=AWm8B)!qWcEW{gqC;}$f+pxvxHB7++J|5lv6^}l^H^jXiBATLjK?- zf&w#Cc7HL=$k`&4C8CzPbRrpzGY2oqz5``#)c0Vq{LhiKOjlr?<#;qiqF=!r0Z zj%hCdBMSaA{P(Aq?Z|d94XJV<#LI?Z#pDVG3nL8-IX62aupEe{i37A$!kV9Yt9X-AQd%wUcVFRt( zh9KWRIJO`Fdp2ZaNT6j@fXxQlfwO0X+(!U4ov9TXP)}_D`g{T2uGxs}9RmQrU>vNb SJu(RZ0000lLJ`dGjcUh9ybznZuw;S*1MPa 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

#p`&MW4^dQMQUsq3sNWv*%OgpIzx3sh<;x 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 + + + + + diff --git a/desktop/src/main.rs b/desktop/src/main.rs new file mode 100644 index 00000000..1927de29 --- /dev/null +++ b/desktop/src/main.rs @@ -0,0 +1,512 @@ +//! CodeTrial as a desktop app: one window showing the `codetrial web` server +//! this process starts, on a loopback port it owns for as long as it runs. +//! +//! The page talks to nothing native. There is no command, capability or +//! plugin permission a page can reach; everything the window does, the +//! process decides from outside the page. docs/desktop.md has the boundary. + +// A release build is a GUI program and must not open a console beside its +// window. Debug builds keep it, which is where the server's output goes. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod navigation; +mod server; + +use std::collections::HashSet; +use std::io::Write as _; +use std::path::Path; +use std::sync::{LazyLock, Mutex, OnceLock, mpsc}; +use std::time::Duration; + +use tauri::webview::{DownloadEvent, NewWindowResponse, PermissionKind, PermissionResponse}; +use tauri::{AppHandle, Manager, RunEvent, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + +use navigation::{Ask, Verdict}; + +const WINDOW: &str = "main"; + +/// Fixed rather than chosen at launch, because the browser keys what the page +/// stores locally, practice history among it, by origin: a port that moved +/// would show an empty history every launch. 3101 so that it does not meet the +/// CLI's default of 3000 on a machine that runs both. +const SERVER_ADDR: &str = "127.0.0.1:3101"; + +static SERVER_URL: LazyLock = LazyLock::new(|| { + Url::parse(&format!("http://{SERVER_ADDR}/")).expect("the server address is a URL") +}); + +/// How long the server gets to exit after its stdin closes before it is +/// killed. +const STOP_GRACE: Duration = Duration::from_secs(5); + +/// How long the page has to answer `CLOSE_PROBE` before the question is put +/// to the person instead. +const PAGE_ANSWER: Duration = Duration::from_secs(5); + +/// Asks the page's own leave-page guard, the one `web/before-unload.js` arms +/// while an interview's report is unsaved, by firing the event it listens for. +/// A window close sends no `beforeunload` of its own, so without this a close +/// would discard a live interview without asking. +const CLOSE_PROBE: &str = r#"(() => { + try { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented ? "guarded" : "free"; + } catch (_) { + return "guarded"; + } +})()"#; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Phase { + /// Waiting for both the server to be ready and the starting page to say + /// whether this web view has WebRTC. See `open_server`. + Starting { + server_ready: bool, + webrtc: Option, + }, + Serving, + /// Serving, to the default browser rather than to this window, because + /// this window cannot make the call. + InBrowser, + Failed, +} + +/// Where a close stands. One value rather than two flags, so "quitting but not +/// closing" is not a state anything can reach. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Close { + Open, + /// Being decided; another close request joins it. + Asking, + /// Decided; the server's exit is expected and not reported. + Quitting, +} + +struct Shell { + server: Mutex>, + phase: Mutex, + close: Mutex, + start_page: OnceLock, +} + +impl Shell { + fn quitting(&self) -> bool { + *self.close.lock().expect("close lock") == Close::Quitting + } +} + +fn main() { + let app = tauri::Builder::default() + // First, so a second launch is turned away before it starts a server of + // its own on the same port. + .plugin(tauri_plugin_single_instance::init(|app, _, _| { + if let Some(window) = app.get_webview_window(WINDOW) { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + })) + .plugin(tauri_plugin_dialog::init()) + .manage(Shell { + server: Mutex::new(None), + phase: Mutex::new(Phase::Starting { + server_ready: false, + webrtc: None, + }), + close: Mutex::new(Close::Open), + start_page: OnceLock::new(), + }) + .setup(|app| { + open_window(app.handle())?; + launch(app.handle()); + Ok(()) + }) + .on_window_event(|window, event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + request_quit(window.app_handle()); + } + }) + .build(tauri::generate_context!()) + .expect("the CodeTrial window should build"); + + app.run(|app, event| match event { + // Cmd+Q and the like arrive here with no code; `app.exit` below arrives + // with one and is let through. Held even while a quit is under way: a + // second Cmd+Q then would end the process before `quit` reaches the + // kill for a server that ignored its closed stdin, and `request_quit` + // already ignores a request while one is being decided or carried out. + RunEvent::ExitRequested { api, code, .. } => { + if code.is_none() { + api.prevent_exit(); + request_quit(app); + } + } + RunEvent::Exit => { + if let Some(server) = app + .state::() + .server + .lock() + .expect("server lock") + .take() + { + server.stop(STOP_GRACE); + } + } + _ => {} + }); +} + +/// A download is asked for and finished inside the page, so without this a +/// refused or failed one looks like a click that did nothing, and the report it +/// was meant to export is gone. +fn notify_download(app: &AppHandle, kind: MessageDialogKind, message: &str) { + app.dialog() + .message(message) + .title("Download") + .kind(kind) + .show(|_| {}); +} + +fn open_window(app: &AppHandle) -> tauri::Result<()> { + let downloads = app.path().download_dir().ok(); + let given = Mutex::new(HashSet::new()); + let navigation_app = app.clone(); + let window = WebviewWindowBuilder::new(app, WINDOW, WebviewUrl::App("index.html".into())) + .title("CodeTrial") + .inner_size(1280.0, 860.0) + .min_inner_size(960.0, 640.0) + .on_navigation(move |url| { + // Before the start page is recorded the window is loading it, and + // that first navigation is the only one there is. + let shell = navigation_app.state::(); + let Some(start_page) = shell.start_page.get() else { + return true; + }; + match navigation::judge(url, &SERVER_URL, start_page) { + // The server's port only while this process is serving it + // there: after a failure, or with the interview in the browser, + // history or a link must not take the window back to whatever + // now answers on it. + Verdict::Server => *shell.phase.lock().expect("phase lock") == Phase::Serving, + Verdict::StartPage(Ask::Nothing) => true, + Verdict::StartPage(Ask::Reopen) => { + open_in_browser(SERVER_URL.as_str()); + false + } + Verdict::StartPage(Ask::WebRtc(webrtc)) => { + if let Phase::Starting { webrtc: known, .. } = + &mut *shell.phase.lock().expect("phase lock") + { + *known = Some(webrtc); + } + open_server(&navigation_app); + false + } + Verdict::OpenInBrowser => { + open_in_browser(url.as_str()); + false + } + Verdict::Refuse => false, + } + }) + // `target="_blank"` and `window.open`. A second webview would be a + // second window into the same origin with none of the rules above, so a + // web page goes to the browser and nothing opens here. + .on_new_window(|url, _| { + if matches!(url.scheme(), "http" | "https") { + open_in_browser(url.as_str()); + } + NewWindowResponse::Deny + }) + // The camera and the microphone, to the server's page and to nothing + // else. Allowed rather than left to the webview, which on Linux has no + // prompt of its own and refuses; the operating system still asks before + // this app first gets either device. + .on_permission_request(move |webview, kind| { + let ours = webview + .url() + .is_ok_and(|url| navigation::same_origin(&url, &SERVER_URL)); + match kind { + PermissionKind::Camera | PermissionKind::Microphone if ours => { + PermissionResponse::Allow + } + _ => PermissionResponse::Deny, + } + }) + // A report export is a download, and a webview drops one unless told + // where to put it. + .on_download(move |webview, event| match event { + DownloadEvent::Requested { destination, .. } => match &downloads { + Some(dir) => { + let mut given = given.lock().expect("download names lock"); + *destination = navigation::download_path(dir, destination, &given); + given.insert(destination.clone()); + true + } + None => { + notify_download( + webview.app_handle(), + MessageDialogKind::Error, + "The report was not saved: this computer names no Downloads folder.", + ); + false + } + }, + DownloadEvent::Finished { path, success, .. } => { + // No longer in flight, so the name is the folder's to decide + // again: a saved file is found on disk by the next one. A + // failure that reports no path keeps its name reserved, which + // only numbers the next download. + if let Some(path) = &path { + given.lock().expect("download names lock").remove(path); + } + if success { + let saved = path + .map(|path| format!("Saved to {}.", path.display())) + .unwrap_or_else(|| "Saved to your Downloads folder.".to_string()); + notify_download(webview.app_handle(), MessageDialogKind::Info, &saved); + } else { + notify_download( + webview.app_handle(), + MessageDialogKind::Error, + "The report was not saved. Check that your Downloads folder exists \ + and has room, then download it again.", + ); + } + true + } + _ => true, + }) + .build()?; + + let _ = app.state::().start_page.set(window.url()?); + + #[cfg(target_os = "linux")] + window.with_webview(|webview| { + use webkit2gtk::{SettingsExt, WebViewExt}; + if let Some(settings) = WebViewExt::settings(&webview.inner()) { + settings.set_enable_media_stream(true); + settings.set_enable_webrtc(true); + } + })?; + Ok(()) +} + +fn launch(app: &AppHandle) { + let prepared = (|| -> Result { + let data_dir = app + .path() + .app_local_data_dir() + .map_err(|error| format!("cannot find a folder for CodeTrial's data: {error}"))?; + std::fs::create_dir_all(&data_dir) + .map_err(|error| format!("cannot create {}: {error}", data_dir.display()))?; + Ok(server::Launch { + program: server::program_path()?, + data_dir, + addr: SERVER_ADDR.to_string(), + }) + })(); + let launch = match prepared { + Ok(launch) => launch, + Err(message) => return show_failure(app, &message, None), + }; + + let data_dir = launch.data_dir.clone(); + let reporter = app.clone(); + let started = server::start(&launch, move |status| { + let app = &reporter; + match status { + server::Status::Ready => { + let shell = app.state::(); + if let Phase::Starting { server_ready, .. } = + &mut *shell.phase.lock().expect("phase lock") + { + *server_ready = true; + } + open_server(app); + } + server::Status::Exited(message) => { + if !app.state::().quitting() { + show_failure(app, &message, Some(data_dir.as_path())); + } + } + } + }); + match started { + Ok(server) => { + *app.state::().server.lock().expect("server lock") = Some(server); + } + Err(message) => show_failure(app, &message, Some(launch.data_dir.as_path())), + } +} + +/// Shows the server in this window, or in the default browser when this +/// window has no WebRTC, once both halves are known: the server is ready and +/// the starting page has said what it has. Called as each arrives, in either +/// order. +/// +/// The interview is a LiveKit call, which is WebRTC, and WebKitGTK as several +/// Linux distributions build it has none: the page would load, take the camera +/// and then fail to connect. The browser is the route that works there, and +/// this process still starts and stops the server it is pointed at. +/// +/// The starting page reports by navigating rather than being asked, because a +/// script sent to a page still loading is lost with the document it ran in, +/// and the server is often ready before the page is. +fn open_server(app: &AppHandle) { + let shell = app.state::(); + let Some(window) = app.get_webview_window(WINDOW) else { + return; + }; + + // Decided and recorded under the lock, acted on after it is released: the + // navigation below reaches `on_navigation`, which takes the same lock. + let webrtc = { + let mut phase = shell.phase.lock().expect("phase lock"); + let Phase::Starting { + server_ready: true, + webrtc: Some(webrtc), + } = *phase + else { + return; + }; + *phase = if webrtc { + Phase::Serving + } else { + Phase::InBrowser + }; + webrtc + }; + if webrtc { + note("the web view has WebRTC: the interview runs in this window"); + let _ = window.navigate(SERVER_URL.clone()); + return; + } + note("the web view has no WebRTC: the interview runs in the default browser"); + open_in_browser(SERVER_URL.as_str()); + show_start_page(app, &[("browser", "")]); +} + +/// Errors are ignored: there is no better place to report a browser that will +/// not open than the window the person is already looking at, and the starting +/// page there offers to try again. +fn open_in_browser(url: &str) { + let _ = tauri_plugin_opener::open_url(url, None::<&str>); +} + +/// The starting page again, with `pairs` as its whole query. +fn show_start_page(app: &AppHandle, pairs: &[(&str, &str)]) { + let shell = app.state::(); + let (Some(page), Some(window)) = (shell.start_page.get(), app.get_webview_window(WINDOW)) + else { + return; + }; + let mut page = page.clone(); + page.query_pairs_mut().clear().extend_pairs(pairs); + let _ = window.navigate(page); +} + +/// For whoever started the app from a terminal. Ignores a stderr that is +/// gone, which a release build on Windows has from the start. +fn note(message: &str) { + let _ = writeln!(std::io::stderr(), "codetrial-desktop: {message}"); +} + +/// Takes the window off the server's port for good, onto the starting page +/// with the reason. Never back to the port: whatever answers there now is not +/// a server this process started. +fn show_failure(app: &AppHandle, message: &str, data_dir: Option<&Path>) { + *app.state::().phase.lock().expect("phase lock") = Phase::Failed; + let logs = data_dir.map(|dir| dir.display().to_string()); + let mut pairs = vec![("error", message)]; + if let Some(logs) = &logs { + pairs.push(("logs", logs)); + } + show_start_page(app, &pairs); +} + +/// One close at a time, whichever way it was asked for: the window's close +/// button, Cmd+Q, or the dock. A second request while one is being decided +/// joins it rather than starting another dialog. +fn request_quit(app: &AppHandle) { + let shell = app.state::(); + { + let mut close = shell.close.lock().expect("close lock"); + if *close != Close::Open { + return; + } + *close = Close::Asking; + } + let phase = *shell.phase.lock().expect("phase lock"); + if phase == Phase::InBrowser { + return confirm_quit( + app, + "CodeTrial is running in your web browser. Quitting stops it, and an \ + interview in progress there ends without its report.", + ); + } + let Some(window) = app + .get_webview_window(WINDOW) + .filter(|_| phase == Phase::Serving) + else { + return quit(app); + }; + + // The sender lives in the callback, so an eval that could not be sent drops + // it and the wait below ends at once rather than at the timeout. + let (answer, answered) = mpsc::channel(); + let _ = window.eval_with_callback(CLOSE_PROBE, move |result| { + let _ = answer.send(result); + }); + let app = app.clone(); + std::thread::spawn(move || match answered.recv_timeout(PAGE_ANSWER) { + Ok(result) if result.contains("free") => quit(&app), + Ok(_) => confirm_quit( + &app, + "An interview is in progress and its report is not saved yet. If you \ + quit now, the interview ends and the report is lost.", + ), + Err(_) => confirm_quit( + &app, + "CodeTrial is not responding, so it cannot say whether an interview is \ + in progress. Quit anyway?", + ), + }); +} + +fn confirm_quit(app: &AppHandle, message: &str) { + let answer = app.clone(); + app.dialog() + .message(message) + .title("Quit CodeTrial?") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Quit".to_string(), + "Keep CodeTrial open".to_string(), + )) + .show(move |quit_now| { + if quit_now { + quit(&answer); + } else { + *answer.state::().close.lock().expect("close lock") = Close::Open; + } + }); +} + +/// Stops the server, then exits. Off the event loop, because the stop waits +/// for the server and the window has to keep drawing while it does. +fn quit(app: &AppHandle) { + let shell = app.state::(); + *shell.close.lock().expect("close lock") = Close::Quitting; + let server = shell.server.lock().expect("server lock").take(); + let app = app.clone(); + std::thread::spawn(move || { + if let Some(server) = server { + server.stop(STOP_GRACE); + } + app.exit(0); + }); +} diff --git a/desktop/src/navigation.rs b/desktop/src/navigation.rs new file mode 100644 index 00000000..66865bc3 --- /dev/null +++ b/desktop/src/navigation.rs @@ -0,0 +1,97 @@ +//! Which URLs the window may show, and what happens to the rest. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use tauri::Url; + +#[derive(Debug, PartialEq, Eq)] +pub enum Verdict { + /// The server this app started. + Server, + /// The app's own starting page. It also talks to the app this way, by + /// navigating to itself with a query the app intercepts, so it has nothing + /// native to call. Only this page can: the server's own `?reopen` is just a + /// server page. + StartPage(Ask), + /// Any other web page: the default browser shows it instead, so a link the + /// interview carries still works and the window never holds a page whose + /// script could reach the server as same origin. + OpenInBrowser, + Refuse, +} + +/// What the starting page's query asks for. +#[derive(Debug, PartialEq, Eq)] +pub enum Ask { + /// To be shown. + Nothing, + /// `?reopen`: the server again in the default browser, for a tab that was + /// closed. + Reopen, + /// `?webrtc=yes` or `?webrtc=no`: whether this web view can make the + /// interview's call. + WebRtc(bool), +} + +/// Scheme, host and port, compared as written. Not `Url::origin`, which is +/// opaque for `tauri://` and so never equal to itself. +pub fn same_origin(a: &Url, b: &Url) -> bool { + a.scheme() == b.scheme() + && a.host_str() == b.host_str() + && a.port_or_known_default() == b.port_or_known_default() +} + +pub fn judge(url: &Url, server: &Url, start_page: &Url) -> Verdict { + if same_origin(url, server) { + Verdict::Server + } else if same_origin(url, start_page) { + Verdict::StartPage(ask(url)) + } else if matches!(url.scheme(), "http" | "https") { + Verdict::OpenInBrowser + } else { + Verdict::Refuse + } +} + +fn ask(url: &Url) -> Ask { + for (key, value) in url.query_pairs() { + match &*key { + "reopen" => return Ask::Reopen, + "webrtc" => return Ask::WebRtc(value == "yes"), + _ => {} + } + } + Ask::Nothing +} + +/// Where a download is saved: the name the page offered, in `dir`, numbered +/// rather than overwriting a file already there or one already `given` to a +/// download still in flight, which has not written its file yet. Only the +/// last component of the offered name is used, so a name carrying a path +/// cannot leave `dir`. +/// +/// `given` rather than creating the file to reserve the name: a web view told +/// to save onto a file that exists may refuse the download instead. +pub fn download_path(dir: &Path, offered: &Path, given: &HashSet) -> PathBuf { + let name = offered + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("download"); + let (stem, extension) = match name.rsplit_once('.') { + Some((stem, extension)) if !stem.is_empty() => (stem, format!(".{extension}")), + _ => (name, String::new()), + }; + let mut candidate = dir.join(name); + let mut number = 1; + while candidate.exists() || given.contains(&candidate) { + candidate = dir.join(format!("{stem} ({number}){extension}")); + number += 1; + } + candidate +} + +#[cfg(test)] +#[path = "../tests/unit/navigation.rs"] +mod tests; diff --git a/desktop/src/server.rs b/desktop/src/server.rs new file mode 100644 index 00000000..a20b958e --- /dev/null +++ b/desktop/src/server.rs @@ -0,0 +1,262 @@ +//! The `codetrial web` child process: where it lives, how it is started, when +//! it is ready, and how it is stopped. +//! +//! Nothing here touches the window, so all of it can be tested without one. + +use std::collections::VecDeque; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::PathBuf; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// What the server prints once its socket is bound, for Setup and for the full +/// app alike. Enough to load the page: the socket is listening from the bind, +/// so a connection made before the server accepts waits in the backlog rather +/// than being refused, and the Setup socket is handed over to the full app +/// rather than closed. The CLI tests run the real server against this text. +const BOUND_LINE: &str = "codetrial: open http://"; + +/// How much of the server's stderr explains an exit. The reason is its last +/// line; the ones before it are the warnings it printed on the way. +const REASON_LINES: usize = 12; + +const SERVER_LOG: &str = "codetrial-server.log"; + +/// Everything one launch of the server needs, decided by the caller. +pub struct Launch { + pub program: PathBuf, + pub data_dir: PathBuf, + /// `127.0.0.1:PORT`. Also the only `Host` the server will answer, so it has + /// to be the exact spelling the window loads. + pub addr: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Status { + /// Listening on `Launch::addr`. + Ready, + /// Gone, and why, in words for the person looking at the window. + Exited(String), +} + +pub struct Server { + child: Arc>, + stdin: Option, + stopping: Arc, +} + +/// The server program shipped beside this one, or the one +/// `CODETRIAL_DESKTOP_SERVER` names for a development build that has no +/// bundle to sit in. +pub fn program_path() -> Result { + let path = match std::env::var_os("CODETRIAL_DESKTOP_SERVER") { + Some(path) => PathBuf::from(path), + None => std::env::current_exe() + .map_err(|error| format!("cannot locate this program: {error}"))? + .with_file_name(if cfg!(windows) { + "codetrial.exe" + } else { + "codetrial" + }), + }; + if path.is_file() { + Ok(path) + } else { + Err(format!( + "The CodeTrial server program is missing: {}. Reinstall CodeTrial.", + path.display() + )) + } +} + +/// The server, told everything a desktop install needs as flags. A +/// `--data-dir` launch reads no configuration from the environment and makes +/// its own session key in that folder, so neither is decided here. +fn command(launch: &Launch) -> Command { + let mut command = Command::new(&launch.program); + command + .arg("web") + .arg("--data-dir") + .arg(&launch.data_dir) + .arg("--web-addr") + .arg(&launch.addr) + .arg("--allowed-hosts") + .arg(&launch.addr) + // Not for finding the config, which `--data-dir` already decides, but + // for a relative path written into it, such as `CODETRIAL_DB_PATH`: the + // data folder is the one place it can sensibly mean. + .current_dir(&launch.data_dir) + .env("CODETRIAL_EXIT_WITH_STDIN", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // The server is a console program, and a window-subsystem parent starting + // one gets a console window beside it unless told otherwise. + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + std::os::windows::process::CommandExt::creation_flags(&mut command, CREATE_NO_WINDOW); + } + command +} + +/// Starts the server and reports to `report` once: `Ready` and later perhaps +/// `Exited`, or `Exited` alone. Never `Ready` after `Exited`, so a window that +/// has been told the server is gone is not sent back to the port. +pub fn start( + launch: &Launch, + report: impl Fn(Status) + Send + Sync + 'static, +) -> Result { + let log_path = launch.data_dir.join(SERVER_LOG); + let log = Arc::new(Mutex::new(File::create(&log_path).map_err(|error| { + format!("cannot write {}: {error}", log_path.display()) + })?)); + + let mut child = command(launch).spawn().map_err(|error| { + format!( + "cannot start the CodeTrial server {}: {error}", + launch.program.display() + ) + })?; + let stdin = child.stdin.take(); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let child = Arc::new(Mutex::new(child)); + let stopping = Arc::new(AtomicBool::new(false)); + + // One lock decides the order the two reports can arrive in. + let finished = Arc::new(Mutex::new(false)); + let report = Arc::new(report); + let reason = Arc::new(Mutex::new(VecDeque::with_capacity(REASON_LINES))); + + let mut copiers = Vec::new(); + if let Some(stderr) = stderr { + let (log, reason) = (log.clone(), reason.clone()); + copiers.push(std::thread::spawn(move || { + copy_lines(stderr, &log, |line| { + let mut reason = reason.lock().expect("reason lock"); + if reason.len() == REASON_LINES { + reason.pop_front(); + } + reason.push_back(line.to_string()); + }); + })); + } + if let Some(stdout) = stdout { + let (log, finished, report) = (log.clone(), finished.clone(), report.clone()); + copiers.push(std::thread::spawn(move || { + let mut bound = false; + copy_lines(stdout, &log, |line| { + if bound || !line.starts_with(BOUND_LINE) { + return; + } + bound = true; + + // Held across the report: a guard taken in the `if` alone is + // dropped before its body runs, and `Exited` could land + // between. + let finished = finished.lock().expect("report order lock"); + if !*finished { + report(Status::Ready); + } + }); + })); + } + + { + let (child, stopping) = (child.clone(), stopping.clone()); + std::thread::spawn(move || { + let status = loop { + if let Ok(Some(status)) = child.lock().expect("child lock").try_wait() { + break status; + } + std::thread::sleep(Duration::from_millis(100)); + }; + + // The reason is the last lines the server wrote, so they have to be + // read first. Bounded, because a pipe stays open for as long as + // anything the server started still holds it. + let deadline = Instant::now() + Duration::from_secs(1); + while copiers.iter().any(|copier| !copier.is_finished()) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let mut finished = finished.lock().expect("report order lock"); + *finished = true; + if !stopping.load(Ordering::SeqCst) { + let reason = reason.lock().expect("reason lock"); + report(Status::Exited(exit_message( + &status.to_string(), + reason.iter().map(String::as_str), + ))); + } + }); + } + + Ok(Server { + child, + stdin, + stopping, + }) +} + +impl Server { + /// Closes the server's stdin, which it exits on, and kills it if it has + /// not gone within `grace`. + pub fn stop(mut self, grace: Duration) { + self.stopping.store(true, Ordering::SeqCst); + drop(self.stdin.take()); + let deadline = Instant::now() + grace; + loop { + let mut child = self.child.lock().expect("child lock"); + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return; + } + drop(child); + std::thread::sleep(Duration::from_millis(50)); + } + } +} + +/// Lossy rather than `lines()`, which ends at the first line that is not +/// UTF-8: the pipe would then go unread, and a server writing to it blocks once +/// it fills, or dies on the broken pipe once the reader is dropped. +fn copy_lines(stream: impl Read, log: &Mutex, mut each: impl FnMut(&str)) { + let mut reader = BufReader::new(stream); + let mut bytes = Vec::new(); + loop { + bytes.clear(); + match reader.read_until(b'\n', &mut bytes) { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + let text = String::from_utf8_lossy(&bytes); + let line = text.trim_end_matches(['\n', '\r']); + let _ = writeln!(log.lock().expect("log lock"), "{line}"); + each(line); + } +} + +/// Why the server exited, from the last lines it wrote to stderr, which end +/// with the reason when it gave one. +fn exit_message<'a>(status: &str, reason: impl Iterator) -> String { + let reason = reason.collect::>().join("\n"); + let reason = if reason.trim().is_empty() { + "It printed nothing." + } else { + &reason + }; + format!("The CodeTrial server stopped ({status}).\n\n{reason}") +} + +#[cfg(test)] +#[path = "../tests/unit/server.rs"] +mod tests; diff --git a/desktop/tauri.bundle.json b/desktop/tauri.bundle.json new file mode 100644 index 00000000..946eccda --- /dev/null +++ b/desktop/tauri.bundle.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "externalBin": ["../target/desktop/sidecar/codetrial"] + } +} diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json new file mode 100644 index 00000000..eaaf568e --- /dev/null +++ b/desktop/tauri.conf.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "CodeTrial", + "mainBinaryName": "codetrial-desktop", + "identifier": "org.sysprog21.codetrial", + "build": { + "frontendDist": "ui" + }, + "app": { + "windows": [], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'", + "capabilities": [] + } + }, + "bundle": { + "active": true, + "category": "Education", + "shortDescription": "Practice technical interviews with a real-time AI interviewer", + "longDescription": "CodeTrial runs a timed coding interview with an AI interviewer that listens, speaks and reads your code as you write it.", + "copyright": "Copyright (c) sysprog21", + "license": "MIT", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "macOS": { + "minimumSystemVersion": "12.0", + "entitlements": "Entitlements.plist", + "signingIdentity": "-" + }, + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper" + } + } + } +} diff --git a/desktop/tests/unit/navigation.rs b/desktop/tests/unit/navigation.rs new file mode 100644 index 00000000..bf2810b6 --- /dev/null +++ b/desktop/tests/unit/navigation.rs @@ -0,0 +1,133 @@ +use std::collections::HashSet; +use std::path::Path; + +use super::*; + +fn url(text: &str) -> Url { + Url::parse(text).expect("test URLs parse") +} + +#[test] +fn only_the_server_and_the_start_page_load_in_the_window() { + let server = url("http://127.0.0.1:3101/"); + for start in [ + "tauri://localhost/index.html", + "http://tauri.localhost/index.html", + ] { + let start = url(start); + assert_eq!( + judge(&url("http://127.0.0.1:3101/interview?x=1"), &server, &start), + Verdict::Server + ); + assert_eq!( + judge(&start.join("index.html?error=x").unwrap(), &server, &start), + Verdict::StartPage(Ask::Nothing) + ); + + // Another port on the same host is another server, and the name + // `localhost` is another origin for the page's storage and cookies. + assert_eq!( + judge(&url("http://127.0.0.1:3102/"), &server, &start), + Verdict::OpenInBrowser + ); + assert_eq!( + judge(&url("http://localhost:3101/"), &server, &start), + Verdict::OpenInBrowser + ); + assert_eq!( + judge(&url("https://github.com/login"), &server, &start), + Verdict::OpenInBrowser + ); + + for refused in [ + "file:///etc/passwd", + "data:text/html,x", + "javascript:alert(1)", + "about:blank", + ] { + assert_eq!( + judge(&url(refused), &server, &start), + Verdict::Refuse, + "{refused}" + ); + } + } +} + +#[test] +fn only_the_start_page_can_ask_for_the_browser_or_report_webrtc() { + let server = url("http://127.0.0.1:3101/"); + let start = url("tauri://localhost/index.html?browser=x"); + for (page, ask) in [ + ("tauri://localhost/index.html?reopen", Ask::Reopen), + ("tauri://localhost/index.html?webrtc=yes", Ask::WebRtc(true)), + ("tauri://localhost/index.html?webrtc=no", Ask::WebRtc(false)), + ("tauri://localhost/index.html?browser=x", Ask::Nothing), + ("tauri://localhost/index.html?error=x", Ask::Nothing), + ] { + assert_eq!( + judge(&url(page), &server, &start), + Verdict::StartPage(ask), + "{page}" + ); + } + + // The same query from the server is a page of the server's, not a request. + for page in [ + "http://127.0.0.1:3101/?reopen", + "http://127.0.0.1:3101/?webrtc=yes", + ] { + assert_eq!(judge(&url(page), &server, &start), Verdict::Server); + } +} + +#[test] +fn a_download_keeps_its_name_and_never_overwrites() { + let dir = std::env::temp_dir().join(format!( + "codetrial-desktop-download-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let none = HashSet::new(); + + let first = download_path(&dir, Path::new("/tmp/suggested/report.json"), &none); + assert_eq!(first, dir.join("report.json")); + std::fs::write(&first, "").unwrap(); + let second = download_path(&dir, Path::new("report.json"), &none); + assert_eq!(second, dir.join("report (1).json")); + std::fs::write(&second, "").unwrap(); + assert_eq!( + download_path(&dir, Path::new("report.json"), &none), + dir.join("report (2).json") + ); + + assert_eq!( + download_path(&dir, Path::new(""), &none), + dir.join("download") + ); + assert_eq!( + download_path(&dir, Path::new(".profile"), &none), + dir.join(".profile") + ); + assert_eq!( + download_path(&dir, Path::new("../../escape.txt"), &none), + dir.join("escape.txt") + ); + + // Two downloads in flight before either writes: the second is numbered past + // the name the first was given, though nothing is on disk yet. + let mut given = HashSet::new(); + let pending = download_path(&dir, Path::new("notes.txt"), &given); + assert_eq!(pending, dir.join("notes.txt")); + given.insert(pending); + assert_eq!( + download_path(&dir, Path::new("notes.txt"), &given), + dir.join("notes (1).txt") + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/desktop/tests/unit/server.rs b/desktop/tests/unit/server.rs new file mode 100644 index 00000000..df6dd4bb --- /dev/null +++ b/desktop/tests/unit/server.rs @@ -0,0 +1,166 @@ +use std::path::Path; +use std::sync::mpsc; + +use super::*; + +fn temp_dir(label: &str) -> PathBuf { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "codetrial-desktop-{label}-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn the_server_is_started_on_the_data_dir_and_told_the_rest() { + let launch = Launch { + program: PathBuf::from("codetrial"), + data_dir: PathBuf::from("/data"), + addr: "127.0.0.1:3101".to_string(), + }; + let command = command(&launch); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + args, + [ + "web", + "--data-dir", + "/data", + "--web-addr", + "127.0.0.1:3101", + "--allowed-hosts", + "127.0.0.1:3101" + ] + ); + assert_eq!(command.get_current_dir(), Some(Path::new("/data"))); + + // The pipe is the one thing it is told through the environment, which a + // `--data-dir` launch reads no configuration from. + let env = |key: &str| { + command + .get_envs() + .find(|(name, _)| *name == key) + .and_then(|(_, value)| value) + .map(|value| value.to_string_lossy().into_owned()) + }; + assert_eq!(command.get_envs().count(), 1); + assert_eq!(env("CODETRIAL_EXIT_WITH_STDIN").as_deref(), Some("1")); +} + +#[test] +fn an_exit_is_explained_by_what_the_server_last_wrote() { + assert!(exit_message("exit status: 1", std::iter::empty()).ends_with("It printed nothing.")); + + let message = exit_message( + "exit status: 1", + [ + "a warning on the way", + "failed to bind 127.0.0.1:3101: in use", + ] + .into_iter(), + ); + assert!(message.contains("(exit status: 1)"), "{message}"); + assert!( + message.ends_with("a warning on the way\nfailed to bind 127.0.0.1:3101: in use"), + "{message}" + ); +} + +/// A stand-in server: a shell script, so the process handling is what is +/// tested and not the CodeTrial server. +#[cfg(unix)] +fn fake_server(dir: &Path, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let program = dir.join("fake-codetrial"); + std::fs::write(&program, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + program +} + +#[cfg(unix)] +fn launch_of(dir: &Path, program: PathBuf, addr: String) -> Launch { + Launch { + program, + data_dir: dir.to_path_buf(), + addr, + } +} + +#[cfg(unix)] +#[test] +fn a_server_that_dies_at_start_is_reported_with_what_it_said() { + let dir = temp_dir("dies"); + let program = fake_server(&dir, "echo starting; echo 'it went wrong' >&2; exit 3"); + let (sender, received) = mpsc::channel(); + let sender = Mutex::new(sender); + let _server = start( + &launch_of(&dir, program, "127.0.0.1:9".to_string()), + move |status| { + let _ = sender.lock().unwrap().send(status); + }, + ) + .unwrap(); + + let Status::Exited(message) = received.recv_timeout(Duration::from_secs(10)).unwrap() else { + panic!("a server that never bound cannot be ready"); + }; + assert!(message.contains("exit status: 3"), "{message}"); + assert!(message.contains("it went wrong"), "{message}"); + assert!(received.recv_timeout(Duration::from_millis(300)).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Ready on the line the server prints once it is listening, and stopped by +/// its stdin closing. The script waits on its stdin exactly as the server does +/// with `CODETRIAL_EXIT_WITH_STDIN`, so a stop that forgot to close the pipe +/// would fall through to the kill and leave the marker unwritten. +#[cfg(unix)] +#[test] +fn a_server_is_ready_once_bound_and_stops_when_its_stdin_closes() { + let dir = temp_dir("ready"); + let marker = dir.join("stdin-closed"); + let program = fake_server( + &dir, + &format!( + "echo 'codetrial 0.1.0 (abc1234)'\n\ + echo 'codetrial: open http://127.0.0.1:9 in your browser'\n\ + cat >/dev/null\ntouch '{}'", + marker.display() + ), + ); + let (sender, received) = mpsc::channel(); + let sender = Mutex::new(sender); + let server = start( + &launch_of(&dir, program, "127.0.0.1:9".to_string()), + move |status| { + let _ = sender.lock().unwrap().send(status); + }, + ) + .unwrap(); + + assert_eq!( + received.recv_timeout(Duration::from_secs(10)).unwrap(), + Status::Ready + ); + assert!( + received.recv_timeout(Duration::from_millis(300)).is_err(), + "one line, one report" + ); + + server.stop(Duration::from_secs(5)); + assert!( + marker.exists(), + "the server should have seen its stdin close" + ); + + // An exit the app asked for is not news to report. + assert!(received.recv_timeout(Duration::from_millis(500)).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/desktop/ui/index.html b/desktop/ui/index.html new file mode 100644 index 00000000..318a4759 --- /dev/null +++ b/desktop/ui/index.html @@ -0,0 +1,38 @@ + + + + + CodeTrial + + + + +

+

CodeTrial

+

Starting…

+ + +
+ + diff --git a/desktop/ui/start.css b/desktop/ui/start.css new file mode 100644 index 00000000..9bafd099 --- /dev/null +++ b/desktop/ui/start.css @@ -0,0 +1,137 @@ +/* The pages' own visual system, from web/styles.css: the lobby's light palette + on a light system and the interview's warm dark otherwise, one violet + accent, and the same type steps and radii. Copied rather than linked, + because this page loads from the app before any server exists and so cannot + reach the server's stylesheet; the names match, so a value changed there + is found here by name. */ +:root { + color-scheme: light; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + --page: #f6f6f9; + --surface: #ffffff; + --ink: #171d2d; + --sub: #4f5b73; + --hairline: #e3e5ec; + --accent-fill: #6e2bf0; + --accent-deep: #5520c4; + --accent-ink: #5520c4; + --step-surface: var(--page); + --shadow-card: 0 1px 2px rgb(23 29 45 / 4%), 0 8px 24px rgb(23 29 45 / 6%); + --shadow-cta: 0 6px 16px rgb(110 43 240 / 24%); + --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --page: #11110f; + --surface: #1b1b18; + --ink: #f6f3ea; + --sub: #b7b2a5; + --hairline: #2d2b26; + --accent-fill: #744ce6; + --accent-deep: #6437d2; + --accent-ink: #ab9eff; + --step-surface: #151512; + --shadow-card: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 24px rgb(0 0 0 / 24%); + --shadow-cta: 0 6px 16px rgb(116 76 230 / 28%); + } +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + min-height: 100vh; + display: grid; + place-items: center; + padding: 2.5rem 1.25rem; + background: var(--page); + color: var(--ink); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + line-height: 1.55; +} + +:where(:focus-visible) { + outline: 2px solid var(--accent-ink); + outline-offset: 3px; +} + +main { + width: min(100%, 36rem); + padding: 2rem; + border: 1px solid var(--hairline); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-card); +} + +h1 { + margin: 0 0 1rem; + font-size: 1.5rem; + line-height: 1.15; + letter-spacing: -0.02em; +} + +p { + margin: 0; + color: var(--sub); +} + +p + p, +pre + p { + margin-top: 1rem; +} + +code, +pre { + font-family: var(--font-mono); + font-size: 0.875rem; +} + +code { + color: var(--ink); + overflow-wrap: anywhere; +} + +pre { + margin: 1rem 0 0; + padding: 0.75rem 1rem; + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + background: var(--step-surface); + color: var(--ink); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* The page's one action, drawn as the lobby's Start is. */ +.action { + display: inline-block; + padding: 0.75rem 1.25rem; + border-radius: var(--radius-md); + background: var(--accent-fill); + box-shadow: var(--shadow-cta); + color: #ffffff; + font-weight: 700; + text-decoration: none; + transition: background 120ms ease; +} + +.action:hover { + background: var(--accent-deep); +} diff --git a/desktop/ui/start.js b/desktop/ui/start.js new file mode 100644 index 00000000..21f689a8 --- /dev/null +++ b/desktop/ui/start.js @@ -0,0 +1,26 @@ +// The app shows this page while the server starts, again with `?error=` when +// it cannot, and with `?browser=` when the interview runs in the browser. The +// reason is the server's own output, so it is written with textContent only: +// shown, never parsed. +const params = new URLSearchParams(location.search); +const error = params.get("error"); +const browser = params.get("browser"); +if (error !== null) { + document.getElementById("starting").hidden = true; + document.getElementById("failure").hidden = false; + document.getElementById("reason").textContent = error; + const logs = params.get("logs"); + if (logs !== null) { + document.getElementById("logs-dir").textContent = logs; + document.getElementById("logs").hidden = false; + } +} else if (browser !== null) { + document.getElementById("starting").hidden = true; + document.getElementById("browser").hidden = false; +} else { + // Tells the app whether this web view can make the interview's call. A + // navigation the app intercepts and cancels, so the page stays and calls + // nothing native. + const webrtc = typeof RTCPeerConnection === "function" ? "yes" : "no"; + location.replace(`index.html?webrtc=${webrtc}`); +} diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 00000000..246b4ecf --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,230 @@ +# The desktop app + +CodeTrial also ships as a desktop app for macOS, Windows and Linux: one window, +a server it starts and stops itself, and nothing to open in a browser by hand. +It is the same server as `codetrial web`, so the interview, the reports and the +rules in the other documents here are unchanged. What follows is what the app +adds around that server, where it keeps things, and what it has not been +qualified for yet. + +The browser deployment is not going anywhere. Everything below is an +alternative way to run the same program on one person's machine. + +## What it is + +`desktop/` is a [Tauri 2](https://v2.tauri.app/) app. It does not link the +server in: it runs the `codetrial` executable that ships beside it as a child +process, and its window shows the page that child serves on +`http://127.0.0.1:3101`. + +A child process rather than a library call, because the server's startup lives +in `src/main.rs` (config search, the Setup handover, every refusal it makes +before serving) and moving that into the library would have been a rewrite of +the file the CLI's behavior is pinned to. As a child it is the binary the CLI +tests already cover, started with three things a launcher needs that the CLI +did not have. Each is decided in the server, so the app keeps no copy of the +server's rules: + +- `--data-dir DIR`: the config file, the account database, the session key, + the error log and any `web/` override all live in `DIR`, unless the config + file there names another place with `CODETRIAL_DB_PATH` or + `CODETRIAL_WEB_DIR` (a relative one resolves against the working + directory, which the app sets to `DIR`). The working directory and the + executable's folder are never searched or written, because a launcher + chooses neither, and no configuration is read from the environment, because + a shell that exported `CODETRIAL_DB_PATH` or `NODE_ENV` for a CLI deployment + would otherwise move this install's reports depending on how it was started. + The one exception is a first launch: when `DIR` has no config yet and + `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` are exported, those + (and `GOOGLE_API_KEY` or `GOOGLE_API_KEYS`, if set) are written to `DIR`'s config as Setup would + write them, checked the way Setup checks them but not probed, and Setup is + skipped. Nothing else from the environment goes in, and later launches read + only the file. +- `--allowed-hosts LIST` (or `CODETRIAL_ALLOWED_HOSTS`): the `Host` values the + server answers. A page on another origin can repoint its own name at + 127.0.0.1 and read a loopback server as same origin; the name it sends is + the one thing that still gives it away. The app passes `127.0.0.1:3101` and + nothing else. While Setup serves, before any config exists, it applies its + own rule instead: a loopback name on the port it bound, answered with 403 + otherwise. The list applies once the full app runs. +- `CODETRIAL_EXIT_WITH_STDIN`: the server exits when its standard input + closes. The app holds the other end of that pipe, so when the app goes, by + quitting or by crashing, the server goes too and does not keep the port. + This one stays in the environment even for `--data-dir`, because it is not + configuration but a description of the pipe the launcher handed over. + +All three are ordinary server options, and a CLI install can use them the same +way. `codetrial --help` lists the two flags, and the README's configuration +table lists `CODETRIAL_ALLOWED_HOSTS`. + +## Where it keeps things + +One folder per user, chosen by the operating system. On Windows it is the +local one, not the roaming profile, because the folder holds the keys and the +session signing key and a roaming profile is copied to a server: + +| Platform | Folder | +| -------- | ----------------------------------------------------- | +| macOS | `~/Library/Application Support/org.sysprog21.codetrial` | +| Windows | `%LOCALAPPDATA%\org.sysprog21.codetrial` | +| Linux | `$XDG_DATA_HOME/org.sysprog21.codetrial`, usually `~/.local/share/org.sysprog21.codetrial` | + +In it: + +- `codetrial.env.local`: the keys the Setup page takes, in plain text, the + same file the CLI writes. Edit it to change a key or add an option. To skip + Setup, start the app once from a terminal that exports the LiveKit keys (see + `--data-dir` above); an app opened from the Dock, Finder or the Start menu + sees no shell exports. +- `codetrial.db`: accounts and saved reports. +- `session-secret`: the key session cookies are signed with, which the server + makes on its first `--data-dir` launch when the config file sets no + `SESSION_SECRET`, owner-only on macOS and Linux. The CLI's built-in default + is a published string, and any program on the machine can reach the port, so + a data folder never uses it. A damaged file stops the server rather than + being replaced, because replacing it signs everyone out and hides what + damaged it. +- `codetrial-server.log`: everything the server printed on its last launch. +- `codetrial-error.log`: why the last launch failed, when it did. + +What the page itself stores (practice history among it) is the web view's, +keyed by the origin `http://127.0.0.1:3101`. That is why the port is fixed: a +port chosen per launch would be a new origin, and an empty history, every time. +The CLI's default is 3000, so both can run on one machine. If something else +holds 3101, the app says so and does not start. + +The config file in the folder above is the only configuration the server has; +see `--data-dir` above. + +## The window and the page + +The page gets no native access at all: no Tauri command, no capability, no +plugin permission is granted to it. Everything the app does, it decides from +outside the page. + +- Only two origins load in the window: the server's, and the app's own + starting page. Any other web page opens in the default browser, and anything + that is not a web page is refused. `target="_blank"` and `window.open` never + make a second window. +- The camera and the microphone are granted to the server's page and to + nothing else. The operating system still asks before the app first gets + either; on macOS the reasons shown are in `desktop/Info.plist`. +- A download, such as an exported report, is saved to the Downloads folder + under the name the page offered, numbered rather than overwriting. + +Closing the window, or quitting from the menu or the dock, asks the page's own +leave-page guard (`web/before-unload.js`) whether an interview's report is +still unsaved. If it is, the app asks before quitting; if the page does not +answer within five seconds, it asks as well. When the interview is in the +browser instead (see below) the app cannot ask that page, so it always asks. +Then it closes the server's stdin, gives it five seconds, and kills it if it is +still there. + +## Linux and WebRTC + +The interview is a LiveKit call, which is WebRTC. On Linux the window is +WebKitGTK, where WebRTC is a build-time option that a distribution's package may +leave off. Without it the page loads, takes the camera, and then cannot connect. + +So the app's starting page reports whether `RTCPeerConnection` exists before +it shows the interview. If it does not, the app opens the interview in the +default browser instead, keeps the server running, and its window says so, +with a link to open the browser again. The browser and the window are separate +origins as far as stored history goes: a machine that uses the browser route +keeps its history in that browser. + +macOS (WKWebView) and Windows (WebView2) have WebRTC, so this only changes +anything on Linux. + +## What the desktop app does not do yet + +- GitHub sign-in. The OAuth page opens in the browser, and the session it + creates lands there, not in the window. A config with `GITHUB_CLIENT_ID` and + `GITHUB_CLIENT_SECRET` set is a deployment for other people, which is what the + CLI is for. +- Recording, which needs that sign-in (see + [recording-contract.md](recording-contract.md)). +- Signed installers. Until they are signed, macOS refuses the first launch as + from an unidentified developer (allow it under System Settings, Privacy & + Security), and Windows SmartScreen warns. +- Automatic updates. A new version is a new installer. + +None of this makes an interview work offline: LiveKit and Gemini are still +needed, and C, C++ and Java runs still go to Compiler Explorer. + +## What has not been qualified + +The unit tests in the gate prove the app starts and stops a server, and keeps +its window to its own origins. +They do not prove an interview works in a given web view. Before a release is +called more than a preview, each platform needs these run on real hardware, +with the outcome recorded per case: + +1. The camera and microphone prompts appear, and granting them works. +2. The media preflight completes: the meter moves, the face check runs or says + it is unavailable. +3. The interviewer is heard, and the candidate's speech is transcribed. +4. Speaking over the interviewer interrupts it. +5. A Python test run passes. +6. Ending the interview renders the report, and its save is confirmed. +7. Quitting and relaunching lists that report in history. + +Then closing the window and quitting from the menu, in each phase of an +interview, with the report-saved state checked after each. A Chromium run +under Playwright says nothing about WKWebView, WebView2 or WebKitGTK. + +## Building it + +The server first, since the app needs one to start: + +```sh +make build # target/release/codetrial +``` + +On Linux, the app also needs WebKitGTK 4.1 and its friends (Debian and Ubuntu +names; Ubuntu 22.04 is the oldest with 4.1): + +```sh +sudo apt-get install libwebkit2gtk-4.1-dev libxdo-dev libssl-dev librsvg2-dev +``` + +Everything the app's build produces lands in `target/desktop/`, beside the +server's own `target/`: `desktop/.cargo/config.toml` says so for cargo and the +Tauri CLI run from `desktop/`, and `scripts/test.sh` passes the same directory +when it builds from the root. Nothing is written under `desktop/` itself. + +A development run, pointing the app at that server with +`CODETRIAL_DESKTOP_SERVER` because a development build has no bundle for it to +sit in: + +```sh +cd desktop +CODETRIAL_DESKTOP_SERVER=$PWD/../target/release/codetrial cargo run +``` + +Installers come from the Tauri CLI, with the server copied to the name it +expects under `target/desktop/sidecar/`. `tauri.bundle.json` is what adds the +server to the bundle; it is kept out of `tauri.conf.json` so that a plain +`cargo build` of the app does not need a server built first. + +```sh +triple=$(rustc -vV | sed -n 's/^host: //p') +mkdir -p target/desktop/sidecar +cp target/release/codetrial "target/desktop/sidecar/codetrial-$triple" +cd desktop +npx @tauri-apps/cli@2.12.3 build --config tauri.bundle.json +``` + +The installers are under `target/desktop/release/bundle/`. Linux gets `.deb`, +`.rpm` and AppImage; macOS an `.app` in a `.dmg`; Windows an MSI and an NSIS +installer. Each puts `codetrial` beside `codetrial-desktop`, +so the `.deb` and `.rpm` also put the CLI on the `PATH`. + +## Checks + +`scripts/test.sh` reaches the app through two gates, because the root's +`fmt`, `clippy` and `cargo test` never see a crate with its own manifest: +`desktop-fmt` always, and `desktop` (clippy and the unit tests) where the +platform's web view can be linked, which on Linux means `libwebkit2gtk-4.1-dev` +is installed. Without it the gate skips and says so. The formatter chain and +`cargo audit` cover `desktop/` as well. diff --git a/docs/development.md b/docs/development.md index a8153032..044acb8d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -72,13 +72,16 @@ npm ci # ESLint and Prettier, pinned npx playwright install chromium # the browser tests, the whole lobby flow cargo install cargo-audit # the dependency advisory scan cargo install cargo-deny # dependency licenses and sources, deny.toml +sudo apt install libwebkit2gtk-4.1-dev # Linux: clippy and tests of desktop/ ``` CI runs `scripts/test.sh rust`, `scripts/test.sh portable` and `scripts/test.sh browser` in parallel, with fail-fast disabled so a failure in one lane does not hide the others. Clippy and Rust tests share the Rust lane's -build cache; formatting, Python and shell checks run in the portable -lane; Node browser tests have their own lane, except the face-detector suite, +build cache; the desktop app's clippy and tests run in the same lane against +their own lockfile and cache, after it installs WebKitGTK. Formatting, Python +and shell checks run in the portable lane; Node browser tests have their own +lane, except the face-detector suite, which needs the Rust binary and runs after its build. All lanes fetch and verify vendor assets; each restores the vendored-asset cache, and only the portable lane saves it, after its gates pass. Each lane has its own job diff --git a/scripts/audit.py b/scripts/audit.py index bfb984cf..af1c6460 100644 --- a/scripts/audit.py +++ b/scripts/audit.py @@ -11,6 +11,11 @@ from pathlib import Path +# The root's lockfile, then the desktop app's, which is a crate of its own and +# is audited only where it exists. +LOCKFILES = ("Cargo.lock", "desktop/Cargo.lock") + + def git(root, *args): return subprocess.check_output(["git", "-C", str(root), *args]) @@ -71,23 +76,36 @@ def audit(lockfile, database, fetch): raise ValueError(f"audit did not complete: {error}") from error -def scan(head, previous, informational): - """Return head findings, those the base already had, warnings and the DB revision.""" +def scan(trees): + """Return head findings, those the merge adds, warnings and the DB revision. + + `trees` holds one (head, previous, informational) per lockfile. All of them + are read against the one database snapshot the first audit fetches, so no + lockfile is judged by a newer advisory list than another. What a lockfile + adds is judged against its own base, so a finding another lockfile already + had does not excuse the same one arriving in this. + """ + findings, added, warnings, revision = set(), set(), {}, None with tempfile.TemporaryDirectory(prefix="codetrial-audit-") as directory: scratch = Path(directory) database = scratch / "advisory-db" lockfile = scratch / "Cargo.lock" - lockfile.write_bytes(head) - findings, warnings = audit(lockfile, database, True) - revision = git(database, "rev-parse", "HEAD").decode().strip() - if informational: - inherited = findings - elif previous is not None: - lockfile.write_bytes(previous) - inherited, _ = audit(lockfile, database, False) - else: + for head, previous, informational in trees: + lockfile.write_bytes(head) + found, found_warnings = audit(lockfile, database, revision is None) + if revision is None: + revision = git(database, "rev-parse", "HEAD").decode().strip() + for kind, items in found_warnings.items(): + warnings.setdefault(kind, []).extend(items) + findings |= found + if informational: + continue inherited = set() - return findings, inherited, warnings, revision + if previous is not None: + lockfile.write_bytes(previous) + inherited = audit(lockfile, database, False)[0] + added |= found - inherited + return findings, added, warnings, revision def render(lines, findings, added, warnings, revision): @@ -125,18 +143,39 @@ def render(lines, findings, added, warnings, revision): ) -def run(root, base, lines): - head = (root / "Cargo.lock").read_bytes() +def base_lockfile(root, base, name): + """`name` as the base has it, or None when the base has no such file.""" + if name != LOCKFILES[0]: + present = subprocess.run( + ["git", "-C", str(root), "cat-file", "-e", f"{base}:{name}"], + capture_output=True, + ) + if present.returncode != 0: + # A lockfile the merge adds has nothing to inherit: every finding + # in it is the merge's own. A base that does not exist at all + # still fails, on the root's lockfile below. + return None # Failing to read the base is fatal: it decides whether errors may pass. - previous = git(root, "show", f"{base}:Cargo.lock") if base else None - informational = previous == head + return git(root, "show", f"{base}:{name}") + + +def run(root, base, lines): + trees = [] + for name in LOCKFILES: + path = root / name + if name != LOCKFILES[0] and not path.exists(): + continue + head = path.read_bytes() + previous = base_lockfile(root, base, name) if base else None + trees.append((head, previous, previous == head)) + informational = all(unchanged for _, _, unchanged in trees) if informational: mode = "informational (unchanged lockfile)" else: mode = "new vulnerabilities in proposed merge" if base else "full" lines.append("Mode: " + mode) try: - findings, inherited, warnings, revision = scan(head, previous, informational) + findings, added, warnings, revision = scan(trees) except (OSError, ValueError, subprocess.CalledProcessError) as error: if not informational: raise @@ -147,7 +186,6 @@ def run(root, base, lines): "::warning::Security scan incomplete; unchanged lockfile, see audit summary." ) return 0, None - added = findings - inherited render(lines, findings, added, warnings, revision) return int(bool(added)), bool(findings) diff --git a/scripts/indent.sh b/scripts/indent.sh index 80233d52..4d12d9a8 100755 --- a/scripts/indent.sh +++ b/scripts/indent.sh @@ -110,6 +110,10 @@ list() } rust=$(list 'build.rs' 'src/*.rs' 'tests/*.rs' 'scripts/*.rs') + +# The desktop app is a crate of its own, so `cargo fmt` at the root never sees +# it and it needs its manifest named. +desktop=$(list 'desktop/build.rs' 'desktop/src/*.rs' 'desktop/tests/*.rs') shell=$(list '*.sh') # tests/ and the skills as well as scripts/: the Python under tests/ drives the @@ -128,7 +132,7 @@ web=$(list '*.html' '*.js' '*.mjs' '*.cjs' \ '.github/ISSUE_TEMPLATE/*.yml' '.github/ISSUE_TEMPLATE/*.yaml') prettier=$ROOT/node_modules/.bin/prettier -files=$(printf '%s\n%s\n%s\n' "$rust" "$shell" "$python" | grep -v '^$') +files=$(printf '%s\n%s\n%s\n%s\n' "$rust" "$desktop" "$shell" "$python" | grep -v '^$') # The same refusal as scripts/git-pre-commit.sh makes for the linters, here for # the tree, which never goes through the hook. @@ -167,9 +171,9 @@ lane() } lane commentflow "install a release from github.com/sysprog21/commentflow" \ - "$rust$shell" && use_commentflow=1 + "$rust$desktop$shell" && use_commentflow=1 lane rustfmt "install Rust with rustup, then rustup component add rustfmt" \ - "$rust" && use_rustfmt=1 + "$rust$desktop" && use_rustfmt=1 lane ruff "pipx install ruff" "$python" && use_ruff=1 lane shfmt "brew install shfmt, or apt-get install shfmt" "$shell" && use_shfmt=1 lane "$prettier" "run npm ci" "$web" && use_prettier=1 @@ -186,19 +190,33 @@ fi # which formats each file of the crate once: rustfmt handed every file follows # `mod` from the roots and formats the children a second time, 3.2s against 1.2s # here. A commit takes rustfmt on what it staged, so one file does not pay for -# the crate. +# the crate. Each crate is formatted against its own manifest. +# +# rustfmt_crate MANIFEST FILES... +rustfmt_crate() +{ + manifest=$1 + shift + [ $# -gt 0 ] || return 0 + if [ "$mode" != staged ]; then + cargo fmt --manifest-path "$manifest" + else + edition=$(sed -n 's/^edition *= *"\([0-9]*\)".*/\1/p' "$manifest" | sed -n 1p) + rustfmt --edition "${edition:-2024}" "$@" + fi +} + format() { if [ -n "${use_commentflow:-}" ]; then # shellcheck disable=SC2086 - commentflow $rust $shell || return 1 + commentflow $rust $desktop $shell || return 1 fi - if [ -n "${use_rustfmt:-}" ] && [ "$mode" != staged ]; then - cargo fmt || return 1 - elif [ -n "${use_rustfmt:-}" ]; then - edition=$(sed -n 's/^edition *= *"\([0-9]*\)".*/\1/p' Cargo.toml | sed -n 1p) + if [ -n "${use_rustfmt:-}" ]; then + # shellcheck disable=SC2086 + rustfmt_crate Cargo.toml $rust || return 1 # shellcheck disable=SC2086 - rustfmt --edition "${edition:-2024}" $rust || return 1 + rustfmt_crate desktop/Cargo.toml $desktop || return 1 fi if [ -n "${use_ruff:-}" ]; then # shellcheck disable=SC2086 @@ -252,10 +270,12 @@ if [ "$mode" = check ]; then # One tar pipe rather than a mkdir and a cp per file: 81 files was 162 # process spawns and most of this gate's wall clock. Every manifest, because - # `cargo fmt` reads them to find the workspace and each member's files. A - # setting the tree does not have is left out, or tar would refuse the whole - # copy over it. + # `cargo fmt` reads them to find the workspace and each member's files, the + # desktop one included. A setting the tree does not have is left out, or tar + # would refuse the whole copy over it. copy=$files + [ -z "$desktop" ] || copy="$copy +desktop/Cargo.toml" for setting in $configs; do [ -f "$setting" ] && copy="$copy $setting" diff --git a/scripts/test.sh b/scripts/test.sh index 741ba176..f7174a89 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -262,7 +262,8 @@ cargo_audit_gate() fi # The binary rather than `cargo audit`; scripts/audit.py says why. - cargo-audit audit --file "$ROOT/Cargo.lock" + cargo-audit audit --file "$ROOT/Cargo.lock" \ + && cargo-audit audit --file "$ROOT/desktop/Cargo.lock" } cargo_deny_gate() @@ -275,6 +276,25 @@ cargo_deny_gate() cargo deny --manifest-path "$ROOT/Cargo.toml" check licenses sources } +# The desktop app is a crate of its own with its own lockfile, so none of the +# root's fmt, clippy or test gates reach it. Building it needs the platform's +# webview, which on Linux is WebKitGTK's development package: a machine without +# one skips the build and says so, the way a missing linter does. CI installs it +# on every run of the Rust lane, since a skip there fails the run. +desktop_gate() +{ + manifest=$ROOT/desktop/Cargo.toml + if [ "$(uname -s)" = Linux ] \ + && ! pkg-config --exists webkit2gtk-4.1 > /dev/null 2>&1; then + skip "desktop clippy and tests: libwebkit2gtk-4.1-dev builds desktop/" + return 0 + fi + target=$ROOT/target/desktop + cargo clippy --locked --all-targets --manifest-path "$manifest" \ + --target-dir "$target" -- -D warnings \ + && cargo test --locked --manifest-path "$manifest" --target-dir "$target" +} + # The crates under vendor/ carry fixes upstream has not released. A bump that # outruns a vendored copy brings the registry crate back beside it, or in its # place with only a Cargo warning, so no vendored name may resolve from a @@ -451,6 +471,8 @@ gate --lane rust cargo-test cargo test --locked --manifest-path "$ROOT/Cargo.tom # These tests start the Rust server; running them before its build silently # skips. gate --lane rust browser-server-tests browser_tests server +gate desktop-fmt cargo fmt --check --manifest-path "$ROOT/desktop/Cargo.toml" +gate --lane rust desktop desktop_gate gate gen-problems "$PYTHON" "$ROOT/scripts/gen-problems.py" --check diff --git a/tests/browser/setup-page.test.js b/tests/browser/setup-page.test.js index abbd0b26..7164831d 100644 --- a/tests/browser/setup-page.test.js +++ b/tests/browser/setup-page.test.js @@ -85,6 +85,54 @@ test("setup palette stays aligned with the lobby", async (t) => { } }); +// The desktop app's starting page loads before any server can serve the lobby's +// stylesheet, so it carries a copy; this is what keeps the copy from drifting. +test("desktop start page palette stays aligned with the lobby", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const startCss = read("desktop/ui/start.css"); + const startTokens = [ + ...new Set( + [...startCss.matchAll(/^\s+(--[a-z-]+):/gm)].map((match) => match[1]), + ), + ]; + assert.ok(startTokens.length > 0, "start page tokens exist"); + for (const colorScheme of ["light", "dark"]) { + const startPage = await browser.newPage({ colorScheme }); + const lobbyPage = await browser.newPage({ colorScheme }); + t.after(() => Promise.all([startPage.close(), lobbyPage.close()])); + await startPage.setContent(""); + await startPage.addStyleTag({ content: startCss }); + await lobbyPage.setContent(''); + await lobbyPage.addStyleTag({ content: lobbyCss }); + + const tokensOn = (page, selector) => + page.locator(selector).evaluate((element, names) => { + const style = getComputedStyle(element); + return Object.fromEntries( + names.map((name) => [name, style.getPropertyValue(name)]), + ); + }, startTokens); + const startValues = await tokensOn(startPage, ":root"); + const lobbyValues = await tokensOn(lobbyPage, ".practice-page"); + const compact = (value) => value.replace(/\s+/g, ""); + let compared = 0; + for (const name of startTokens) { + // A token the lobby does not define is the start page's own. + if (!lobbyValues[name].trim()) continue; + compared++; + assert.equal( + compact(startValues[name]), + compact(lobbyValues[name]), + `${colorScheme} ${name} differs from the lobby`, + ); + } + assert.ok( + compared >= 8, + `${colorScheme}: only ${compared} tokens compared`, + ); + } +}); + test("setup shows distinct credential groups without narrow-screen overflow", async (t) => { if (!browser) return t.skip("playwright chromium unavailable"); for (const colorScheme of ["light", "dark"]) { diff --git a/tests/cli.rs b/tests/cli.rs index 1ae006a5..36d97b83 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2779,6 +2779,86 @@ fn a_first_data_dir_launch_writes_the_exported_setup_keys() { assert_eq!(mode, 0o600, "{mode:o}"); } +/// The first line of the child's stdout that says where it is listening, or +/// nothing when it prints none within a few seconds. Read on a thread: a server +/// keeps its stdout open, so a line that never comes would otherwise hang the +/// test instead of failing it. +fn printed_bound_line(server: &mut ServerProcess) -> Option { + use std::io::BufRead as _; + + let stdout = server.stdout.take().expect("stdout is piped"); + let (sender, receiver) = std::sync::mpsc::channel(); + thread::spawn(move || { + for line in std::io::BufReader::new(stdout) + .lines() + .map_while(Result::ok) + { + if line.starts_with("codetrial: open http://") { + let _ = sender.send(line); + return; + } + } + }); + receiver.recv_timeout(Duration::from_secs(10)).ok() +} + +/// The desktop app starts the server and waits for this line, and nothing else +/// tells it the window can load the page, so each way the server starts has to +/// print it, naming the address it bound. +#[test] +fn a_cold_start_prints_the_address_it_bound_for_a_launcher() { + let home = exe_temp_path("bound-line-cold-exe"); + std::fs::create_dir_all(&home).unwrap(); + let exe = binary_beside(&home); + let (addr, mut server) = spawn_cold_start(&exe, &home, &[]); + let line = printed_bound_line(&mut server); + drop(server); + let _ = std::fs::remove_dir_all(&home); + + assert_eq!( + line, + Some(format!( + "codetrial: open http://{addr} in your browser to continue setup" + )) + ); +} + +#[test] +fn a_configured_launch_prints_the_address_it_bound_for_a_launcher() { + let home = exe_temp_path("bound-line-full-exe"); + let data = temp_path("bound-line-full-data"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&data).unwrap(); + let exe = binary_beside(&home); + let livekit_addr = spawn_livekit_room_service("key", "secret"); + std::fs::write( + data.join("codetrial.env.local"), + format!( + "LIVEKIT_URL=http://{livekit_addr}\nLIVEKIT_API_KEY=key\nLIVEKIT_API_SECRET=secret\n" + ), + ) + .unwrap(); + let data_arg = data.to_str().unwrap().to_string(); + let (addr, mut server) = spawn_server(|addr| { + let mut command = Command::new(&exe); + scrubbed(&mut command) + .args(["web", "--web-addr", addr, "--data-dir", &data_arg]) + .current_dir(&home) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + }); + let line = printed_bound_line(&mut server); + drop(server); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&data); + + assert_eq!( + line, + Some(format!("codetrial: open http://{addr} in your browser")) + ); +} + /// The shell that started the first launch may export the same keys again on /// the next one. The folder already has its config then, and the launch has to /// serve from it and leave it as it was, not fail on writing it a second time. diff --git a/tests/test_audit.py b/tests/test_audit.py index 3f3f48d4..94a801be 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -263,7 +263,16 @@ def test_tracking_notifies_only_when_findings_change_and_preserves_retry(self): elif call[1] != "create": self.assertEqual(call[2], "42") - def run_audit(self, base, head, *, full=False, invalid_base=False): + def run_audit( + self, + base, + head, + *, + full=False, + invalid_base=False, + desktop_base=None, + desktop_head=None, + ): with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / "scripts").mkdir() @@ -281,9 +290,17 @@ def git(*args): lockfile = root / "Cargo.lock" lockfile.write_text(json.dumps(base)) git("add", "Cargo.lock") + desktop = root / "desktop/Cargo.lock" + if desktop_base is not None: + desktop.parent.mkdir() + desktop.write_text(json.dumps(desktop_base)) + git("add", "desktop/Cargo.lock") git("commit", "--quiet", "-m", "base") sha = git("rev-parse", "HEAD").decode().strip() lockfile.write_text(json.dumps(head)) + if desktop_head is not None: + desktop.parent.mkdir(exist_ok=True) + desktop.write_text(json.dumps(desktop_head)) command = [sys.executable, str(script), "--report", str(root / "report.md")] if not full: command += ["--base", "missing" if invalid_base else sha] @@ -435,6 +452,51 @@ def test_base_scan_errors_and_missing_bases_block(self): self.assertIn("Scan incomplete", report) self.assertEqual(outputs, "") + def test_the_desktop_lockfile_blocks_what_it_adds_on_one_snapshot(self): + result, report, _, calls = self.run_audit( + {"findings": []}, + {"findings": []}, + desktop_base={"findings": [A]}, + desktop_head={"findings": [A, B]}, + ) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("ctutils 0.4.3` (inherited)", report) + self.assertIn("other 1.0.0` (blocking)", report) + # The root once, unchanged, then the desktop head and its base, all + # against the database the first audit fetched. + self.assertEqual(len(calls), 3) + self.assertNotIn("--no-fetch", calls[0]) + self.assertTrue(all("--no-fetch" in call for call in calls[1:])) + databases = {call[call.index("--db") + 1] for call in calls} + self.assertEqual(len(databases), 1) + + def test_a_desktop_lockfile_the_merge_adds_inherits_nothing(self): + result, report, _, _ = self.run_audit( + {"findings": []}, {"findings": []}, desktop_head={"findings": [A]} + ) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("ctutils 0.4.3` (blocking)", report) + + def test_the_root_having_a_finding_does_not_excuse_a_new_lockfile(self): + result, report, _, _ = self.run_audit( + {"findings": [A]}, + {"findings": [A]}, + desktop_head={"findings": [A]}, + ) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("ctutils 0.4.3` (blocking)", report) + + def test_an_unchanged_desktop_lockfile_is_informational(self): + result, report, _, calls = self.run_audit( + {"findings": []}, + {"findings": []}, + desktop_base={"findings": [A]}, + desktop_head={"findings": [A]}, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("(inherited)", report) + self.assertEqual(len(calls), 2) + def test_warnings_remain_visible_without_blocking(self): result, report, _, _ = self.run_audit( {"findings": []}, diff --git a/tests/test_test_gate.py b/tests/test_test_gate.py index 6d31abab..be5b8c06 100644 --- a/tests/test_test_gate.py +++ b/tests/test_test_gate.py @@ -200,7 +200,13 @@ def test_parallel_lanes_cover_the_default_gate(self): self.assertEqual(rust & portable, shared) self.assertEqual( rust - shared, - {"clippy", "cargo-test", "browser-server-tests", "cargo-deny"}, + { + "clippy", + "cargo-test", + "browser-server-tests", + "cargo-deny", + "desktop", + }, ) self.assertEqual(rust & browser, shared) self.assertEqual(portable & browser, shared) diff --git a/web/before-unload.js b/web/before-unload.js index 3b5077b4..bb76e97c 100644 --- a/web/before-unload.js +++ b/web/before-unload.js @@ -5,6 +5,11 @@ export function shouldWarnBeforeUnload({ endsAt, reportPersisted }) { } /// The browser owns the confirmation text; the page can only request it. +/// +/// The desktop app asks the same question before it closes its window, by +/// dispatching a cancelable `beforeunload` itself and reading +/// `defaultPrevented`, since a window close sends none. That is why `warn` +/// calls `preventDefault` rather than only setting `returnValue`. export function createBeforeUnloadGuard(target = window) { let enabled = false; const warn = (event) => { From d062ae78d3204478a836ce529afb2290a307ae3f Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sun, 18 Oct 2026 04:04:46 +0800 Subject: [PATCH 6/8] Package the desktop app on all three platforms The desktop app has to reach people as installers, and the gate's unit tests never start a real server inside a real window. The desktop workflow builds the server and the app on Linux, macOS and Windows, packages them, and runs scripts/desktop-smoke.sh against each build: the server must answer, refuse a foreign Host, and exit when the app is killed outright. A desktop-vX.Y.Z tag whose version matches desktop/Cargo.toml publishes the installers as a prerelease, apart from the CLI's rolling latest release. Close #289 --- .github/workflows/desktop.yml | 215 ++++++++++++++++++++++++++++++++++ docs/desktop.md | 15 ++- docs/development.md | 4 + scripts/desktop-smoke.sh | 93 +++++++++++++++ 4 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/desktop.yml create mode 100755 scripts/desktop-smoke.sh diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 00000000..e4d28ec4 --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,215 @@ +# The desktop app's installers, built on the three platforms they are for. +# +# A workflow of its own rather than a leg of check.yml's `build`, because the +# two publish different things on different schedules: `build` refreshes the +# rolling CLI release on every push to main, and a desktop release is a +# version somebody chose to cut. Nothing here touches the CLI artifacts or the +# `latest` release. +# +# Pull requests run it only when the desktop app itself changed, because each +# leg builds the server in release mode as well as the app around it. A change +# to the server's side of the launch contract is still caught on Linux: the +# check job's Rust lane compiles and tests desktop/ on every run. +name: desktop + +on: + pull_request: + paths: + - desktop/** + - scripts/desktop-smoke.sh + - .github/workflows/desktop.yml + push: + branches: [main] + paths: + - desktop/** + - scripts/desktop-smoke.sh + - .github/workflows/desktop.yml + tags: ['desktop-v*'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: desktop-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') && github.ref != 'refs/heads/main' }} + +env: + # The CLI that bundles, pinned like every other tool the workflows run. Kept + # on the same minor as the `tauri` crate in desktop/Cargo.toml. + TAURI_CLI_VERSION: 2.12.3 + +jobs: + package: + name: ${{ matrix.name }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + # 22.04 rather than the 24.04 the gates use: the bundle links the + # WebKitGTK it is built against, and the oldest one an installer + # should still start on is the floor. 22.04 is the first Ubuntu with + # WebKitGTK 4.1, which Tauri 2 requires. + - name: Linux x86_64 + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + extension: '' + bundles: deb,rpm,appimage + pr_bundles: deb + - name: macOS arm64 + os: macos-15 + target: aarch64-apple-darwin + extension: '' + bundles: app,dmg + pr_bundles: dmg + - name: Windows x86_64 + os: windows-2025 + target: x86_64-pc-windows-msvc + extension: .exe + bundles: msi,nsis + pr_bundles: nsis + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: >- + sudo apt-get update && sudo apt-get install -y libglib2.0-dev + libwebkit2gtk-4.1-dev libxdo-dev libssl-dev librsvg2-dev file xvfb + - name: Install WebRTC C++ compiler + if: runner.os == 'Linux' + uses: ./.github/actions/webrtc-clang + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: desktop-${{ matrix.target }} + workspaces: | + . -> target + desktop -> target/desktop + + # The server embeds the page and its vendored runtimes, so the same + # fetch and check the CLI release makes, for the same reason: a missing + # asset compiles into a server that boots and then strands the candidate. + - name: Restore vendored bytes + uses: ./.github/actions/restore-vendor + - name: Fetch browser assets + run: ./scripts/fetch-vendor.sh + shell: bash + - name: Verify the assets about to be embedded + run: ./scripts/verify-vendor.sh + shell: bash + + # The flags check.yml's release build passes, for the reasons it gives: + # a static CRT on Windows, and `-ObjC` so WebRTC's Objective-C categories + # survive the link on macOS. Here and not job-wide, because they are the + # server's and the app around it links without them. + - name: Build the server + shell: bash + env: + RUSTFLAGS: >- + ${{ matrix.target == 'x86_64-pc-windows-msvc' && '-C target-feature=+crt-static' + || contains(matrix.target, '-apple-') && '-Clink-arg=-ObjC' + || '' }} + run: cargo build --release --locked --target ${{ matrix.target }} + + # Where tauri.bundle.json's `externalBin` looks: the name Tauri expects + # carries the target triple, and the installer drops it again. + - name: Place the server beside the app + shell: bash + run: | + mkdir -p target/desktop/sidecar + cp target/${{ matrix.target }}/release/codetrial${{ matrix.extension }} \ + target/desktop/sidecar/codetrial-${{ matrix.target }}${{ matrix.extension }} + + # A pull request builds one installer per platform, which is enough to + # prove the bundle config still packages; each further format downloads + # its own packaging tool on every run. + # + # `NO_STRIP`, because the AppImage tool's own strip is older than the + # toolchain and fails on sections it does not know. Both programs are + # already stripped by their release profiles, so nothing is lost. + - name: Build the installers + working-directory: desktop + shell: bash + env: + NO_STRIP: 'true' + run: >- + npx --yes "@tauri-apps/cli@$TAURI_CLI_VERSION" build --ci + --target ${{ matrix.target }} + --bundles ${{ github.event_name == 'pull_request' && matrix.pr_bundles || matrix.bundles }} + --config tauri.bundle.json + + # The built app, not the installer, but with the server beside it the + # way every installer puts it. + - name: Start the app, then kill it + shell: bash + run: | + app=target/desktop/${{ matrix.target }}/release/codetrial-desktop${{ matrix.extension }} + if [ "$RUNNER_OS" = Linux ]; then + xvfb-run -a sh scripts/desktop-smoke.sh "$app" + else + sh scripts/desktop-smoke.sh "$app" + fi + + - uses: actions/upload-artifact@v7 + with: + name: codetrial-desktop-${{ matrix.target }} + if-no-files-found: error + path: | + target/desktop/${{ matrix.target }}/release/bundle/deb/*.deb + target/desktop/${{ matrix.target }}/release/bundle/rpm/*.rpm + target/desktop/${{ matrix.target }}/release/bundle/appimage/*.AppImage + target/desktop/${{ matrix.target }}/release/bundle/dmg/*.dmg + target/desktop/${{ matrix.target }}/release/bundle/msi/*.msi + target/desktop/${{ matrix.target }}/release/bundle/nsis/*.exe + + # A tag names a version, and the version is the one desktop/Cargo.toml + # says: an installer reporting 0.1.0 under a desktop-v0.2.0 release is the + # mismatch this refuses before anything is published. + release: + needs: package + if: startsWith(github.ref, 'refs/tags/desktop-v') + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + pattern: codetrial-desktop-* + path: dist + merge-multiple: true + - name: Publish the release + shell: bash + run: | + tag=${GITHUB_REF#refs/tags/} + version=$(sed -n 's/^version *= *"\([^"]*\)"$/\1/p' desktop/Cargo.toml | head -n 1) + [ "$tag" = "desktop-v$version" ] || { + echo "tag $tag does not match desktop/Cargo.toml version $version" >&2 + exit 1 + } + ls dist + cat > notes.md < /dev/null; rm -rf "$work"' EXIT HUP INT TERM + +fail() +{ + echo "desktop-smoke: $*" >&2 + echo "--- app output" >&2 + cat "$work/app.log" >&2 + exit 1 +} + +status() +{ + curl -s --connect-timeout 1 -m 2 -o "$work/body" -w '%{http_code}' -H "Host: $1" "http://$addr/" 2> /dev/null +} + +# Its own data folder, so a run on a developer's machine does not meet that +# machine's install: Linux takes it from XDG, macOS from HOME. Windows takes it +# from the system, which on a CI runner is empty anyway. +# +# Without the Setup keys too, which a first launch writes into that folder from +# the environment and skips Setup for, so a shell that exports them would fail +# the check below. +mkdir -p "$work/data" "$work/config" "$work/cache" +( + unset LIVEKIT_URL LIVEKIT_API_KEY LIVEKIT_API_SECRET GOOGLE_API_KEY GOOGLE_API_KEYS + HOME=$work XDG_DATA_HOME=$work/data XDG_CONFIG_HOME=$work/config \ + XDG_CACHE_HOME=$work/cache exec "$app" +) > "$work/app.log" 2>&1 & +pid=$! + +# One deadline for the whole wait, not a count of probes: a listener that +# accepts and then hangs would otherwise stretch each probe to its own limit. +served= +deadline=$(($(date +%s) + 60)) +while [ "$(date +%s)" -lt "$deadline" ]; do + if [ "$(status "$addr")" = 200 ]; then + served=yes + break + fi + kill -0 "$pid" 2> /dev/null || fail "the app exited before its server answered" + sleep 0.5 +done +[ -n "$served" ] || fail "nothing answered on $addr within a minute" + +# A fresh install has no config, so what answers is Setup. +grep -q 'CodeTrial - Setup' "$work/body" \ + || fail "$addr answered, but not with the Setup page" + +# The name a rebinding page would arrive with. 403 because Setup is serving, and +# Setup refuses any non-loopback name itself; the full app's allowlist would +# answer 421. +code=$(status "rebound.example:3101") +[ "$code" = 403 ] || fail "a foreign Host was answered $code, not 403" + +kill -9 "$pid" +wait "$pid" 2> /dev/null +pid= + +for _ in $(seq 1 20); do + [ "$(status "$addr")" = 000 ] && { + echo "desktop-smoke: the server answered, refused a foreign Host, and exited with the app" + exit 0 + } + sleep 0.25 +done +fail "the server on $addr outlived the app" From 658c6f9baf0eb20f7f7162889181ed8633f5f151 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sat, 24 Oct 2026 09:42:27 +0800 Subject: [PATCH 7/8] Fill Setup from a chosen config file Setup asked for every key by hand, even from someone whose keys were already saved in a config file, a CLI install's or another machine's. An import button on the page reads a chosen file in the browser, by the rules the server reads config files with, and fills the form. Nothing is sent until the form is saved, so the keys are checked as typed ones are. The form saves one Google key, so a GOOGLE_API_KEYS list gives its first and the page says so. A file input rather than a native dialog: the page is the server's and has no native access, and it opens in a browser too, where the desktop app's web view cannot run the interview. --- README.md | 5 +- src/web/setup.rs | 100 +++++++++++++++++++++++ tests/browser/setup-page.test.js | 136 +++++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f307fae5..3a7c0ae3 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,9 @@ file. Extra `codetrial.env.*` files in the same directory are optional providers used for pooling. If `web` mode finds no config file at all, it serves a Setup page and prints the -URL to open, so steps 2 and 3 above can be skipped: fill in your keys there and -it writes `config/codetrial.env.local` for you. That page serves on a loopback +URL to open, so steps 2 and 3 above can be skipped: fill in your keys there, or +import them from a config file saved earlier, and it writes +`config/codetrial.env.local` for you. That page serves on a loopback address only and refuses any other, so writing the file yourself is the way to deploy for other people. See [docs/install.md](docs/install.md). diff --git a/src/web/setup.rs b/src/web/setup.rs index ab8879f7..c177e512 100644 --- a/src/web/setup.rs +++ b/src/web/setup.rs @@ -244,6 +244,23 @@ const SETUP_PAGE: &str = r#" transition: background 120ms ease; } button:not(:disabled):hover { background: var(--accent-deep); } + button.secondary { background: transparent; color: var(--accent-ink); } + button.secondary:not(:disabled):hover { background: var(--practice-tint); } + .import-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--hairline); + } + .import-row small { flex: 1 1 14rem; margin-top: 0; } + /* The button opens it; the rule for every input above would show it. */ + .import-row input[type="file"] { display: none; } + #import-status { flex-basis: 100%; } + #import-status.error { color: var(--critical); } + #import-status.ok { color: var(--good); } button:disabled { cursor: not-allowed; opacity: 0.6; } #status { flex: 1 1 14rem; min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; } #status.error { color: var(--critical); } @@ -270,6 +287,12 @@ const SETUP_PAGE: &str = r#"

Connect your services to start practicing live technical interviews.

+
+ + + Fills the fields below from a codetrial.env.local saved earlier. Nothing is sent until you save. + +
LiveKit credentials Required

Use the URL and keys from the same LiveKit project.

@@ -310,6 +333,83 @@ const SETUP_PAGE: &str = r#" const form = document.getElementById('setup-form'); const status = document.getElementById('status'); const submit = document.getElementById('submit'); + const importButton = document.getElementById('import'); + const importFile = document.getElementById('import-file'); + const importStatus = document.getElementById('import-status'); + + // The fields a config file can fill, by the key the file spells them with. + const IMPORTED = { + LIVEKIT_URL: 'livekitUrl', + LIVEKIT_API_KEY: 'livekitApiKey', + LIVEKIT_API_SECRET: 'livekitApiSecret', + GOOGLE_API_KEY: 'googleApiKey', + }; + + // The rules of read_config_file in src/config.rs, so a file the server reads + // fills the form with what the server would read from it: blank lines and # + // comments skipped, every other line KEY=value, both sides trimmed, and a + // quote stripped from either end of the value. A line that is not KEY=value + // is refused, as the server refuses it, rather than skipped. By number, never + // by its text: the message is shown, and may be read aloud or shared on a + // screen, and a malformed line can hold a secret. + function parseConfig(text) { + const values = {}; + const lines = text.split(/\r?\n/); + for (let number = 1; number <= lines.length; number++) { + const trimmed = lines[number - 1].trim(); + if (trimmed === '' || trimmed.startsWith('#')) continue; + const equals = trimmed.indexOf('='); + if (equals < 0) throw new Error('line ' + number + ' is not KEY=value'); + values[trimmed.slice(0, equals).trim()] = trimmed + .slice(equals + 1) + .trim() + .replace(/^"+|"+$/g, '') + .replace(/^'+|'+$/g, ''); + } + return values; + } + + function showImport(className, text) { + importStatus.className = className; + importStatus.textContent = text; + } + + importButton.addEventListener('click', () => importFile.click()); + importFile.addEventListener('change', async () => { + const file = importFile.files[0]; + // Cleared so that choosing the same file again, after editing it, reads it + // again instead of firing no change at all. + importFile.value = ''; + if (!file) return; + let values; + try { + values = parseConfig(await file.text()); + } catch (error) { + showImport('error', file.name + ': ' + error.message); + return; + } + if (!['LIVEKIT_URL', 'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET'].every((key) => values[key])) { + showImport('error', file.name + ' does not set LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET.'); + return; + } + + // GOOGLE_API_KEYS outranks GOOGLE_API_KEY, as it does when the server reads + // the file. The form saves one key, so a rotation list gives its first and + // says so; the rest can be added to the saved config later. + let note = ''; + const rotation = (values.GOOGLE_API_KEYS || '').split(',').map((key) => key.trim()).filter(Boolean); + if (rotation.length > 0) { + values.GOOGLE_API_KEY = rotation[0]; + if (rotation.length > 1) { + note = ' GOOGLE_API_KEYS lists ' + rotation.length + + ' keys; Setup saves the first, and the rest can be added to the saved config.'; + } + } + for (const [key, name] of Object.entries(IMPORTED)) { + form.elements[name].value = values[key] || ''; + } + showImport('ok', 'Filled from ' + file.name + '. Check the values, then save.' + note); + }); async function waitForRestart() { // The server keeps the port and swaps what answers on it, so a request landing in diff --git a/tests/browser/setup-page.test.js b/tests/browser/setup-page.test.js index 7164831d..79ff15ea 100644 --- a/tests/browser/setup-page.test.js +++ b/tests/browser/setup-page.test.js @@ -210,3 +210,139 @@ test("setup validates required fields and preserves retry after server error", a }, ]); }); + +const configFile = (name, text) => ({ + name, + mimeType: "text/plain", + buffer: Buffer.from(text), +}); + +test("setup fills its fields from a chosen config file without sending it", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + submissions = []; + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/setup`); + assert.notEqual( + await page + .locator("#import-status") + .evaluate((element) => getComputedStyle(element).display), + "none", + "the import status region is present before its first update", + ); + assert.equal( + await page.locator("#import-file").isVisible(), + false, + "the button stands in for the file input", + ); + + // What read_config_file accepts: comments, blank lines, spaces around the + // equals sign, quotes at either end, and keys Setup does not take. + await page + .locator("#import-file") + .setInputFiles( + configFile( + "codetrial.env.local", + [ + "# saved by the CLI", + "", + 'LIVEKIT_URL = "wss://example.livekit.cloud"', + "LIVEKIT_API_KEY=key", + "LIVEKIT_API_SECRET='secret'", + "GOOGLE_API_KEY=google", + "CODETRIAL_DB_PATH=/elsewhere/codetrial.db", + ].join("\r\n"), + ), + ); + await page.locator("#import-status.ok").waitFor(); + assert.match( + await page.locator("#import-status").innerText(), + /^Filled from codetrial\.env\.local\./, + ); + assert.equal(submissions.length, 0, "importing sends nothing"); + for (const [field, value] of [ + ["#livekitUrl", "wss://example.livekit.cloud"], + ["#livekitApiKey", "key"], + ["#livekitApiSecret", "secret"], + ["#googleApiKey", "google"], + ]) { + assert.equal(await page.locator(field).inputValue(), value, field); + } + + await page.locator("#submit").click(); + await page.locator("#status.error").waitFor(); + assert.deepEqual(submissions, [ + { + livekitUrl: "wss://example.livekit.cloud", + livekitApiKey: "key", + livekitApiSecret: "secret", + googleApiKey: "google", + }, + ]); +}); + +test("setup takes the first key of an imported rotation list and says so", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/setup`); + await page + .locator("#import-file") + .setInputFiles( + configFile( + "rotation.env", + [ + "LIVEKIT_URL=wss://example.livekit.cloud", + "LIVEKIT_API_KEY=key", + "LIVEKIT_API_SECRET=secret", + "GOOGLE_API_KEY=single", + "GOOGLE_API_KEYS= first , second ", + ].join("\n"), + ), + ); + await page.locator("#import-status.ok").waitFor(); + assert.equal(await page.locator("#googleApiKey").inputValue(), "first"); + assert.match( + await page.locator("#import-status").innerText(), + /GOOGLE_API_KEYS lists 2 keys; Setup saves the first/, + ); +}); + +test("setup refuses an imported file it cannot read and keeps the fields", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/setup`); + await page.locator("#livekitApiKey").fill("typed"); + + await page + .locator("#import-file") + .setInputFiles( + configFile( + "broken.env", + "LIVEKIT_URL=wss://example.livekit.cloud\nnot a setting\n", + ), + ); + await page.locator("#import-status.error").waitFor(); + assert.equal( + await page.locator("#import-status").innerText(), + "broken.env: line 2 is not KEY=value", + ); + assert.equal(await page.locator("#livekitApiKey").inputValue(), "typed"); + + await page + .locator("#import-file") + .setInputFiles( + configFile("partial.env", "LIVEKIT_URL=wss://example.livekit.cloud\n"), + ); + // The status already shows the last refusal, so wait for this one. + await page + .locator("#import-status.error", { hasText: "partial.env" }) + .waitFor(); + assert.match( + await page.locator("#import-status").innerText(), + /^partial\.env does not set LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET\.$/, + ); + assert.equal(await page.locator("#livekitApiKey").inputValue(), "typed"); + assert.equal(await page.locator("#livekitUrl").inputValue(), ""); +}); From 1f183993fb16384bed26bdede6d5c25a1c9914e6 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sat, 24 Oct 2026 09:42:29 +0800 Subject: [PATCH 8/8] Offer the CLI's keys on a desktop's first Setup A machine that already runs the CLI keeps its keys in the CLI's config, and the desktop app reads only its own data folder, so its first launch asked for them again. The app now passes the CLI config in the folder it starts in, and nowhere above it, and Setup offers it: the page names the file and its LiveKit URL, and Use these keys has the server read the file and save its Setup keys, checked as typed ones are. Applied only when the page asks, since a file found where the app started could be any project's and its LiveKit project is where interviews would go, and never shown to the page, since any account on the machine can reach the port while Setup serves. A malformed line in a config is named by number, not quoted, because it can hold a secret and the message reaches a window and a log. Exported keys are still written without asking. --- desktop/src/main.rs | 3 + desktop/src/server.rs | 27 +++- desktop/tests/unit/server.rs | 53 ++++++++ docs/desktop.md | 34 ++++- src/config.rs | 21 ++- src/main.rs | 14 +- src/web/setup.rs | 217 ++++++++++++++++++++++++++++--- tests/browser/setup-page.test.js | 87 +++++++++++++ tests/cli.rs | 205 +++++++++++++++++++++++++++++ tests/config.rs | 21 +++ tests/unit/web/setup.rs | 91 ++++++++++++- 11 files changed, 742 insertions(+), 31 deletions(-) diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 1927de29..6c9ffd79 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -307,6 +307,9 @@ fn launch(app: &AppHandle) { program: server::program_path()?, data_dir, addr: SERVER_ADDR.to_string(), + import_config: std::env::current_dir() + .ok() + .and_then(|dir| server::cli_config_in(&dir)), }) })(); let launch = match prepared { diff --git a/desktop/src/server.rs b/desktop/src/server.rs index a20b958e..7dba9cbb 100644 --- a/desktop/src/server.rs +++ b/desktop/src/server.rs @@ -6,7 +6,7 @@ use std::collections::VecDeque; use std::fs::File; use std::io::{BufRead, BufReader, Read, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -25,6 +25,10 @@ const REASON_LINES: usize = 12; const SERVER_LOG: &str = "codetrial-server.log"; +/// Where the CLI keeps its config, relative to the directory it starts in: +/// `config/` in a checkout first, then a bare file beside it. +const CLI_CONFIGS: [&str; 2] = ["config/codetrial.env.local", "codetrial.env.local"]; + /// Everything one launch of the server needs, decided by the caller. pub struct Launch { pub program: PathBuf, @@ -32,6 +36,21 @@ pub struct Launch { /// `127.0.0.1:PORT`. Also the only `Host` the server will answer, so it has /// to be the exact spelling the window loads. pub addr: String, + /// A CLI config whose keys Setup offers to use on a first launch. The + /// server decides what is offered and what using it writes. + pub import_config: Option, +} + +/// The CLI config in `start`: the CLI's own two places there and nowhere else. +/// Never a folder above it, which could be any project's: only a file the +/// person points at, or the one beside where they started the app, is offered. +/// An app opened from the Dock, Finder or the Start menu starts where nothing +/// is found, and Setup asks as it always has. +pub fn cli_config_in(start: &Path) -> Option { + CLI_CONFIGS + .map(|name| start.join(name)) + .into_iter() + .find(|path| path.is_file()) } #[derive(Debug, PartialEq, Eq)] @@ -84,7 +103,11 @@ fn command(launch: &Launch) -> Command { .arg("--web-addr") .arg(&launch.addr) .arg("--allowed-hosts") - .arg(&launch.addr) + .arg(&launch.addr); + if let Some(path) = &launch.import_config { + command.arg("--import-config").arg(path); + } + command // Not for finding the config, which `--data-dir` already decides, but // for a relative path written into it, such as `CODETRIAL_DB_PATH`: the // data folder is the one place it can sensibly mean. diff --git a/desktop/tests/unit/server.rs b/desktop/tests/unit/server.rs index df6dd4bb..0c3ec45b 100644 --- a/desktop/tests/unit/server.rs +++ b/desktop/tests/unit/server.rs @@ -21,6 +21,7 @@ fn the_server_is_started_on_the_data_dir_and_told_the_rest() { program: PathBuf::from("codetrial"), data_dir: PathBuf::from("/data"), addr: "127.0.0.1:3101".to_string(), + import_config: None, }; let command = command(&launch); let args = command @@ -54,6 +55,57 @@ fn the_server_is_started_on_the_data_dir_and_told_the_rest() { assert_eq!(env("CODETRIAL_EXIT_WITH_STDIN").as_deref(), Some("1")); } +#[test] +fn a_cli_config_found_is_offered_to_the_server_to_import() { + let launch = Launch { + program: PathBuf::from("codetrial"), + data_dir: PathBuf::from("/data"), + addr: "127.0.0.1:3101".to_string(), + import_config: Some(PathBuf::from("/checkout/config/codetrial.env.local")), + }; + let args = command(&launch) + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + args[args.len() - 2..], + ["--import-config", "/checkout/config/codetrial.env.local"] + ); +} + +/// Only the start folder is read, and in it `config/` comes before a bare file, +/// as the CLI searches them. +#[test] +fn the_cli_config_is_looked_for_in_the_start_folder_and_nowhere_else() { + let root = temp_dir("cli-config"); + let start = root.join("checkout").join("desktop"); + std::fs::create_dir_all(&start).unwrap(); + let above = root.join("checkout").join("codetrial.env.local"); + let above_config = root + .join("checkout") + .join("config") + .join("codetrial.env.local"); + std::fs::create_dir_all(above_config.parent().unwrap()).unwrap(); + std::fs::write(&above, "").unwrap(); + std::fs::write(&above_config, "").unwrap(); + // A file in a folder above is another project's as far as the app knows. + assert_eq!(cli_config_in(&start), None); + + let bare = start.join("codetrial.env.local"); + let in_config = start.join("config").join("codetrial.env.local"); + std::fs::create_dir_all(in_config.parent().unwrap()).unwrap(); + std::fs::write(&bare, "").unwrap(); + assert_eq!(cli_config_in(&start).as_deref(), Some(bare.as_path())); + std::fs::write(&in_config, "").unwrap(); + assert_eq!(cli_config_in(&start).as_deref(), Some(in_config.as_path())); + // A folder of that name is not a config. + std::fs::remove_file(&in_config).unwrap(); + std::fs::create_dir_all(&in_config).unwrap(); + assert_eq!(cli_config_in(&start).as_deref(), Some(bare.as_path())); + + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn an_exit_is_explained_by_what_the_server_last_wrote() { assert!(exit_message("exit status: 1", std::iter::empty()).ends_with("It printed nothing.")); @@ -90,6 +142,7 @@ fn launch_of(dir: &Path, program: PathBuf, addr: String) -> Launch { program, data_dir: dir.to_path_buf(), addr, + import_config: None, } } diff --git a/docs/desktop.md b/docs/desktop.md index 1160272e..1f0a9a01 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -21,7 +21,7 @@ A child process rather than a library call, because the server's startup lives in `src/main.rs` (config search, the Setup handover, every refusal it makes before serving) and moving that into the library would have been a rewrite of the file the CLI's behavior is pinned to. As a child it is the binary the CLI -tests already cover, started with three things a launcher needs that the CLI +tests already cover, started with four things a launcher needs that the CLI did not have. Each is decided in the server, so the app keeps no copy of the server's rules: @@ -40,6 +40,22 @@ server's rules: write them, checked the way Setup checks them but not probed, and Setup is skipped. Nothing else from the environment goes in, and later launches read only the file. +- `--import-config PATH`: a config Setup offers to use. The page names `PATH` + and the scheme and host of its LiveKit URL, and says whether it holds a + Google key, but never shows a key, the rest of the URL or a line it could not + read: any account on the machine can reach the port while Setup serves. + "Use these keys" has the server read `PATH` and save its Setup keys, checked + as typed ones are, and nothing else in it, so a CLI deployment's database + path or room prefix stays the CLI's. A `PATH` that cannot be used is named + with the reason instead. Offered, never applied by itself: a file found where + the app started could be any project's, and its LiveKit project is where + interviews would go, so it is used only when the page asks for it. Any + account on the machine that can reach the port while Setup serves can make + that request, as it can already post keys of its own. The app passes the CLI config in the folder it was started in, + `config/codetrial.env.local` or `codetrial.env.local`, and looks nowhere + else, so a file in a folder above is never offered. A development run from a + checkout's `desktop/` uses the file picker, or starts from the checkout's + root. - `--allowed-hosts LIST` (or `CODETRIAL_ALLOWED_HOSTS`): the `Host` values the server answers. A page on another origin can repoint its own name at 127.0.0.1 and read a loopback server as same origin; the name it sends is @@ -53,8 +69,8 @@ server's rules: This one stays in the environment even for `--data-dir`, because it is not configuration but a description of the pipe the launcher handed over. -All three are ordinary server options, and a CLI install can use them the same -way. `codetrial --help` lists the two flags, and the README's configuration +All four are ordinary server options, and a CLI install can use them the same +way. `codetrial --help` lists the three flags, and the README's configuration table lists `CODETRIAL_ALLOWED_HOSTS`. ## Where it keeps things @@ -72,10 +88,14 @@ session signing key and a roaming profile is copied to a server: In it: - `codetrial.env.local`: the keys the Setup page takes, in plain text, the - same file the CLI writes. Edit it to change a key or add an option. To skip - Setup, start the app once from a terminal that exports the LiveKit keys (see - `--data-dir` above); an app opened from the Dock, Finder or the Start menu - sees no shell exports. + same file the CLI writes. Edit it to change a key or add an option. The first + launch writes it from exported LiveKit keys without asking (see `--data-dir` + above); otherwise Setup asks, and offers to use a CLI config found in the + folder the app was started in (see `--import-config`). An app opened from the Dock, + Finder or the Start menu sees no shell exports and starts where no CLI config + is, so Setup's "Import from a config file" button is the way to bring one in: + it fills the form from a file you choose. Either way it asks once: later + launches read this file. - `codetrial.db`: accounts and saved reports. - `session-secret`: the key session cookies are signed with, which the server makes on its first `--data-dir` launch when the config file sets no diff --git a/src/config.rs b/src/config.rs index 2e0ce630..1a043749 100644 --- a/src/config.rs +++ b/src/config.rs @@ -484,14 +484,25 @@ fn provider_from_file(id: &str, path: &Path, production: bool) -> Result Result, String> { let text = std::fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + + // By number, never by its text: the message reaches the console, a log file + // and a launcher's window, and a malformed line can hold a secret. + parse_config(&text) + .map_err(|number| format!("{}: line {number} is not KEY=value", path.display())) +} + +/// The pairs in `text`, or the number, counted from 1, of the first line that +/// is not `KEY=value`. Apart from `read_config_file` for a caller that has to +/// say which line without repeating it: the line can hold a secret. +pub(crate) fn parse_config(text: &str) -> Result, usize> { let mut values = Vec::new(); - for line in text.lines() { + for (index, line) in text.lines().enumerate() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } let Some((key, value)) = trimmed.split_once('=') else { - return Err(format!("invalid config line: {trimmed}")); + return Err(index + 1); }; values.push(( key.trim().to_string(), @@ -1061,9 +1072,9 @@ impl fmt::Debug for RecordingLivekit { } /// The authority of a URL, or the whole string when it has no scheme. Shared by -/// the redaction above and the project comparison below, so that neither can -/// mistake a credential for part of a hostname. -fn livekit_authority(url: &str) -> &str { +/// the redaction above, the project comparison below and Setup's offer, so +/// that none of them can mistake a credential for part of a hostname. +pub(crate) fn livekit_authority(url: &str) -> &str { url.split_once("://") .map_or(url, |(_, rest)| rest) .split(['/', '?', '#']) diff --git a/src/main.rs b/src/main.rs index 7d15021e..5e6766ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ const SESSION_SECRET_FILE: &str = "session-secret"; struct CliOptions { config_path: Option, data_dir: Option, + import_config: Option, web_addr: Option, web_dir: Option, allowed_hosts: Option, @@ -208,6 +209,7 @@ fn parse_agent_args(args: &[String]) -> Result<(Vec, CliOptions), String match flag { "--config" => options.config_path = Some(value), "--data-dir" => options.data_dir = Some(PathBuf::from(value)), + "--import-config" => options.import_config = Some(PathBuf::from(value)), "--web-addr" => options.web_addr = Some(value), "--allowed-hosts" if value.trim().is_empty() => { return Err("--allowed-hosts requires a Host".to_string()); @@ -239,6 +241,15 @@ fn parse_agent_args(args: &[String]) -> Result<(Vec, CliOptions), String .to_string(), ); } + + // It is offered on a data folder's first Setup page, and without a data + // folder the CLI searches for the file itself. + if options.import_config.is_some() && options.data_dir.is_none() { + return Err( + "--import-config needs --data-dir: it is offered on a data folder's Setup page" + .to_string(), + ); + } Ok((positionals, options)) } @@ -260,7 +271,7 @@ fn print_help(usage: Option<&str>) { } } println!( - "\nOptions:\n --config PATH Use PATH instead of config/codetrial.env.local\n --data-dir DIR Keep the config, accounts and error log in DIR only\n --web-addr ADDR Listen on ADDR\n --allowed-hosts LIST Answer only these comma-separated Host values\n --web-dir PATH Serve files from PATH\n --room-prefix PREFIX Prefix generated room names\n --duration-min MIN Set the interview duration\n -h, --help Show this help\n -V, --version Show the package version and build commit\n\nEvery option also takes `--flag=value`, which is the only way to pass a value\nthat starts with a dash. `--` ends the options." + "\nOptions:\n --config PATH Use PATH instead of config/codetrial.env.local\n --data-dir DIR Keep the config, accounts and error log in DIR only\n --import-config PATH Offer the Setup keys in PATH on DIR's Setup page\n --web-addr ADDR Listen on ADDR\n --allowed-hosts LIST Answer only these comma-separated Host values\n --web-dir PATH Serve files from PATH\n --room-prefix PREFIX Prefix generated room names\n --duration-min MIN Set the interview duration\n -h, --help Show this help\n -V, --version Show the package version and build commit\n\nEvery option also takes `--flag=value`, which is the only way to pass a value\nthat starts with a dash. `--` ends the options." ); } @@ -603,6 +614,7 @@ fn serve_setup( is_production(values), bound.port(), cold_start_config_path(options), + options.import_config.clone(), ); axum::serve(listener, service) .with_graceful_shutdown(async move { ready.notified().await }) diff --git a/src/web/setup.rs b/src/web/setup.rs index c177e512..e6a3fe21 100644 --- a/src/web/setup.rs +++ b/src/web/setup.rs @@ -30,6 +30,13 @@ use crate::runtime::bootstrap; /// rest of the path rules live there and a second answer here could disagree /// with the search the next launch performs. /// +/// `offer` is a config a launcher found, such as the CLI's, whose keys the +/// page offers to use. Only a summary of it reaches the page, the LiveKit host +/// and whether a Google key is there: any account on the machine can reach this +/// loopback port while Setup serves, and the file's own permissions may keep +/// some of them out of it. Using it is a submission the server reads the file +/// for. +/// /// No `/healthz`. Nothing polls it here any more, and the full app routes it, /// so its absence is the readiness answer a probe wants: a container /// healthcheck against a process that has no config and is serving a @@ -40,12 +47,26 @@ pub fn setup_service( production: bool, port: u16, config_path: PathBuf, + offer: Option, ) -> Router { + let summarized = offer.clone(); Router::new() .route("/", get(setup_page)) + .route( + "/api/setup/offer", + get(move || async move { offer_summary(summarized.as_deref()) }), + ) .route( "/api/setup", - post(move |body| submit_setup(body, ready.clone(), production, config_path.clone())), + post(move |body| { + submit_setup( + body, + ready.clone(), + production, + config_path.clone(), + offer.clone(), + ) + }), ) .layer(middleware::from_fn( move |request: Request, next: Next| async move { @@ -258,9 +279,19 @@ const SETUP_PAGE: &str = r#" .import-row small { flex: 1 1 14rem; margin-top: 0; } /* The button opens it; the rule for every input above would show it. */ .import-row input[type="file"] { display: none; } - #import-status { flex-basis: 100%; } - #import-status.error { color: var(--critical); } - #import-status.ok { color: var(--good); } + .offer-row:not([hidden]) { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--hairline); + } + #offer-text { flex: 1 1 14rem; min-width: 0; margin: 0; overflow-wrap: anywhere; } + #import-status, #offer-status { flex-basis: 100%; margin-top: 0; } + #import-status.error, #offer-status.error { color: var(--critical); } + #import-status.ok, #offer-status.ok { color: var(--good); } button:disabled { cursor: not-allowed; opacity: 0.6; } #status { flex: 1 1 14rem; min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; } #status.error { color: var(--critical); } @@ -287,6 +318,11 @@ const SETUP_PAGE: &str = r#"

Connect your services to start practicing live technical interviews.

+
@@ -336,6 +372,10 @@ const SETUP_PAGE: &str = r#" const importButton = document.getElementById('import'); const importFile = document.getElementById('import-file'); const importStatus = document.getElementById('import-status'); + const offer = document.getElementById('offer'); + const offerText = document.getElementById('offer-text'); + const useOffer = document.getElementById('use-offer'); + const offerStatus = document.getElementById('offer-status'); // The fields a config file can fill, by the key the file spells them with. const IMPORTED = { @@ -428,16 +468,18 @@ const SETUP_PAGE: &str = r#" location.reload(); } - form.addEventListener('submit', async (event) => { - event.preventDefault(); + // Both ways to save go through here: the form's fields, or `{ useOffer: true }`, + // which the server answers from the offered config without the keys passing + // through this page. `line` is the status next to whichever was pressed. + async function save(data, line, button) { // A second submission would reach a config file the first one has already // written, and the write refuses to overwrite: the double-click would report // a failure over a setup that worked. Re-enabled only where the form is // still the way forward, which a successful submission is not. submit.disabled = true; - status.className = ''; - status.textContent = 'Checking credentials…'; - const data = Object.fromEntries(new FormData(form).entries()); + useOffer.disabled = true; + line.className = ''; + line.textContent = 'Checking credentials…'; let response; try { response = await fetch('/api/setup', { @@ -446,22 +488,64 @@ const SETUP_PAGE: &str = r#" body: JSON.stringify(data), }); } catch (error) { - status.className = 'error'; - status.textContent = 'Request failed: ' + error; + line.className = 'error'; + line.textContent = 'Request failed: ' + error; submit.disabled = false; + useOffer.disabled = false; + button.focus(); return; } const body = await response.json().catch(() => ({})); if (response.ok) { - status.className = 'ok'; - status.textContent = 'Saved. Starting codetrial…'; + line.className = 'ok'; + line.textContent = 'Saved. Starting codetrial…'; waitForRestart(); } else { - status.className = 'error'; - status.textContent = body.error || ('Request failed: ' + response.status); + line.className = 'error'; + line.textContent = body.error || ('Request failed: ' + response.status); submit.disabled = false; + useOffer.disabled = false; + button.focus(); } + } + + form.addEventListener('submit', (event) => { + event.preventDefault(); + save(Object.fromEntries(new FormData(form).entries()), status, submit); }); + useOffer.addEventListener('click', () => save({ useOffer: true }, offerStatus, useOffer)); + + // A config the launcher found, such as the CLI's: named here with its LiveKit + // host so the person can tell whose it is before using it. A 404 is no offer. + // `data-checked` is set once the answer has been handled, whichever way it + // went, so a test can tell "nothing offered" from "not asked yet". + (async () => { + try { + let response; + try { + response = await fetch('/api/setup/offer', { cache: 'no-store' }); + } catch (error) { + return; + } + if (!response.ok) return; + const found = await response.json().catch(() => null); + if (!found) return; + if (found.error) { + // Said where the offer is, in text nothing announces unprompted, and + // without the button: the person never tried an import. + offerText.textContent = found.error; + useOffer.hidden = true; + offer.hidden = false; + return; + } + offerText.textContent = 'Keys found in ' + found.path + ': LiveKit ' + found.livekitEndpoint + + (found.google ? ', with a Google key.' : ', without a Google key.') + + (found.note ? ' ' + found.note : ''); + offer.hidden = false; + } finally { + offer.dataset.checked = ''; + } + })(); @@ -779,12 +863,115 @@ fn write_config_file(path: &Path, contents: &str) -> Option { } /// One submission: checked, probed, written, and the app let out behind it. +/// The keys in an offered config, as the form would submit them, with what of +/// the LiveKit URL the page may show and a note for it. +/// +/// Read the way the server reads any config, then cut down to what the form +/// takes, so using it is a submission like a typed one and is checked the +/// same way. The form takes one Google key, so a `GOOGLE_API_KEYS` list, which +/// outranks `GOOGLE_API_KEY` as it does when the server reads the file, gives +/// its first, as importing the file into the form does. +fn read_offer(path: &Path) -> Result<(Value, String, Option), String> { + let shown = path.display().to_string(); + let text = std::fs::read_to_string(path).map_err(|error| format!("{shown}: {error}"))?; + + // By number, never by its text: this reaches the page, which any account on + // the machine can ask for, and a malformed line can hold a secret. + let file = crate::config::parse_config(&text) + .map_err(|number| format!("{shown}: line {number} is not KEY=value"))?; + let mut values = std::collections::BTreeMap::new(); + crate::config::apply_config_file(&mut values, file); + let value = |key: &str| values.get(key).map_or("", |value| value.trim()); + if ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET"] + .iter() + .any(|key| value(key).is_empty()) + { + return Err(format!( + "{shown} does not set LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET." + )); + } + + // Validated before anything of it is shown, by a check whose messages never + // name the URL, and then shown as its scheme and host alone: the user + // information and the query are where a URL carries a credential. + let url = value("LIVEKIT_URL"); + crate::config::validate_livekit_url(url, false).map_err(|error| format!("{shown}: {error}"))?; + let endpoint = format!( + "{}{}", + crate::config::livekit_scheme(url).map_or("", |(scheme, _, _)| scheme), + crate::config::livekit_authority(url) + ); + let rotation = value("GOOGLE_API_KEYS") + .split(',') + .map(str::trim) + .filter(|key| !key.is_empty()) + .collect::>(); + let note = (rotation.len() > 1).then(|| { + format!( + "GOOGLE_API_KEYS lists {} keys; Setup saves the first, and the rest can be added \ + to the saved config.", + rotation.len() + ) + }); + let submission = json!({ + "livekitUrl": value("LIVEKIT_URL"), + "livekitApiKey": value("LIVEKIT_API_KEY"), + "livekitApiSecret": value("LIVEKIT_API_SECRET"), + "googleApiKey": rotation.first().copied().unwrap_or(value("GOOGLE_API_KEY")), + }); + Ok((submission, endpoint, note)) +} + +/// What the page shows of an offered config: its path, its LiveKit scheme and +/// host and whether it holds a Google key, or why it cannot be used. Never a +/// key. +fn offer_summary(offer: Option<&Path>) -> Response { + let Some(path) = offer else { + return super::json_response(StatusCode::NOT_FOUND, json!({})); + }; + let path_shown = path.display().to_string(); + match read_offer(path) { + Ok((submission, endpoint, note)) => super::json_response( + StatusCode::OK, + json!({ + "path": path_shown, + "livekitEndpoint": endpoint, + "google": submission["googleApiKey"].as_str().is_some_and(|key| !key.is_empty()), + "note": note, + }), + ), + Err(error) => super::json_response( + StatusCode::OK, + json!({ "path": path_shown, "error": error }), + ), + } +} + async fn submit_setup( Json(submission): Json, ready: Arc, production: bool, path: PathBuf, + offer: Option, ) -> Response { + // Using the offered config is a submission the server fills in itself, so + // the keys never pass through the page. + let submission = if submission.get("useOffer") == Some(&Value::Bool(true)) { + let Some(offer) = offer else { + return super::json_response( + StatusCode::BAD_REQUEST, + json!({ "error": "no config was offered to use" }), + ); + }; + match read_offer(&offer) { + Ok((submission, _, _)) => submission, + Err(error) => { + return super::json_response(StatusCode::BAD_REQUEST, json!({ "error": error })); + } + } + } else { + submission + }; let fields = match validated_fields(&submission) { Ok(fields) => fields, Err(error) => { diff --git a/tests/browser/setup-page.test.js b/tests/browser/setup-page.test.js index 79ff15ea..6c4fce53 100644 --- a/tests/browser/setup-page.test.js +++ b/tests/browser/setup-page.test.js @@ -23,6 +23,8 @@ let browser = null; let server = null; let base = ""; let submissions = []; +// What /api/setup/offer answers, or null for the 404 of no offer. +let offered = null; before(async () => { browser = await launchChromium(); @@ -34,6 +36,12 @@ before(async () => { response.end(html); return true; } + if (url.pathname === "/api/setup/offer") { + response.statusCode = offered ? 200 : 404; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(offered ?? {})); + return true; + } if (url.pathname === "/api/setup") { let body = ""; for await (const chunk of request) body += chunk; @@ -346,3 +354,82 @@ test("setup refuses an imported file it cannot read and keeps the fields", async assert.equal(await page.locator("#livekitApiKey").inputValue(), "typed"); assert.equal(await page.locator("#livekitUrl").inputValue(), ""); }); + +test("setup offers nothing when the launcher found no config", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + offered = null; + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/setup`); + await page.locator("#offer[data-checked]").waitFor({ state: "attached" }); + assert.equal(await page.locator("#offer").isVisible(), false); + assert.equal(await page.locator("#import-status").innerText(), ""); +}); + +test("setup names an offered config and uses it without the keys", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + submissions = []; + offered = { + path: "/home/me/checkout/config/codetrial.env.local", + livekitEndpoint: "wss://example.livekit.cloud", + google: true, + note: null, + }; + t.after(() => { + offered = null; + }); + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/setup`); + await page.locator("#offer").waitFor(); + assert.equal( + await page.locator("#offer-text").innerText(), + "Keys found in /home/me/checkout/config/codetrial.env.local: LiveKit " + + "wss://example.livekit.cloud, with a Google key.", + ); + + await page.locator("#use-offer").click(); + await page.locator("#offer-status.error").waitFor(); + assert.deepEqual(submissions, [{ useOffer: true }]); + assert.equal( + await page.locator("#offer-status").innerText(), + "Check the LiveKit credentials", + ); + assert.equal(await page.locator("#use-offer").isEnabled(), true); + assert.equal(await page.locator("#submit").isEnabled(), true); + // A refusal hands the keyboard back to the button that was pressed, and the + // button's description is the line naming whose keys it uses. + assert.equal( + await page.evaluate(() => document.activeElement?.id), + "use-offer", + ); + assert.equal( + await page.locator("#use-offer").getAttribute("aria-describedby"), + "offer-text", + ); + for (const field of ["#livekitUrl", "#livekitApiKey", "#livekitApiSecret"]) { + assert.equal(await page.locator(field).inputValue(), "", field); + } +}); + +test("setup reports an offered config it cannot use", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + offered = { + path: "/home/me/codetrial.env.local", + error: + "/home/me/codetrial.env.local does not set LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET.", + }; + t.after(() => { + offered = null; + }); + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/setup`); + await page.locator("#offer").waitFor(); + assert.match( + await page.locator("#offer-text").innerText(), + /^\/home\/me\/codetrial\.env\.local does not set LIVEKIT_URL/, + ); + assert.equal(await page.locator("#use-offer").isVisible(), false); + assert.equal(await page.locator("#import-status").innerText(), ""); +}); diff --git a/tests/cli.rs b/tests/cli.rs index 36d97b83..004394fa 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2904,6 +2904,211 @@ fn a_relaunch_with_the_keys_still_exported_keeps_the_config_it_has() { ); assert_eq!(after, config); } + +/// A first `--data-dir` launch from a temporary home, with `--import-config` +/// naming a file that holds `from` and `env` exported: the address, the +/// server, the data folder, and the home to remove afterwards. +fn launch_offering( + label: &str, + from: &str, + env: &[(&str, &str)], +) -> (String, ServerProcess, PathBuf, PathBuf) { + let home = exe_temp_path(&format!("{label}-exe")); + let data = temp_path(&format!("{label}-data")); + std::fs::create_dir_all(&home).unwrap(); + let exe = binary_beside(&home); + let offered = home.join("cli.env"); + std::fs::write(&offered, from).unwrap(); + + let data_arg = data.to_str().unwrap().to_string(); + let offer_arg = offered.to_str().unwrap().to_string(); + let (addr, server) = spawn_server(|addr| { + let mut command = Command::new(&exe); + scrubbed(&mut command) + .args(["web", "--web-addr", addr, "--data-dir", &data_arg]) + .args(["--import-config", &offer_arg]) + .current_dir(&home) + .envs(env.iter().copied()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + }); + (addr, server, data, home) +} + +fn get(addr: &str, path: &str) -> String { + http_request( + addr, + &format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"), + ) +} + +fn post_setup(addr: &str, body: &str) -> String { + http_request( + addr, + &format!( + "POST /api/setup HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ), + ) +} + +/// A file a launcher came across is not trusted on its own: the first launch +/// still serves Setup and writes nothing, and the page is told which file and +/// which LiveKit project, never a key, since any account on the machine can +/// reach the port while Setup serves. +#[test] +fn a_first_data_dir_launch_offers_the_config_it_is_pointed_at() { + let (addr, server, data, home) = launch_offering( + "data-dir-offer", + "LIVEKIT_URL=wss://example.livekit.cloud\nLIVEKIT_API_KEY=the-api-key\n\ + LIVEKIT_API_SECRET=the-api-secret\nGOOGLE_API_KEY=the-google-key\n", + &[], + ); + let page = get(&addr, "/"); + let offer = get(&addr, "/api/setup/offer"); + let written = data.join("codetrial.env.local").exists(); + drop(server); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&data); + + assert!( + page.contains(r#"name="livekitUrl""#), + "Setup was not served: {page}" + ); + assert!(!written, "an offered config was written without being used"); + assert!(offer.starts_with("HTTP/1.1 200 OK"), "{offer}"); + assert!( + offer.contains(r#""livekitEndpoint":"wss://example.livekit.cloud""#), + "{offer}" + ); + assert!(offer.contains(r#""google":true"#), "{offer}"); + assert!(offer.contains("cli.env"), "{offer}"); + for key in ["the-api-key", "the-api-secret", "the-google-key"] { + assert!(!offer.contains(key), "{key} reached the page: {offer}"); + } +} + +/// Using the offer is a submission the server fills from the file and checks +/// like a typed one, and it writes Setup's keys and nothing else of the file, +/// so a CLI deployment's database path or room prefix stays the CLI's. +#[test] +fn using_the_offered_config_writes_its_setup_keys() { + let livekit_addr = spawn_livekit_room_service("key", "secret"); + let livekit_url = format!("http://{livekit_addr}"); + let (addr, server, data, home) = launch_offering( + "data-dir-offer-use", + &format!( + "# written by the CLI\nLIVEKIT_URL=\"{livekit_url}\"\nLIVEKIT_API_KEY=key\n\ + LIVEKIT_API_SECRET='secret'\nCODETRIAL_ROOM_PREFIX=from-the-cli\n\ + CODETRIAL_DB_PATH=/elsewhere/codetrial.db\n" + ), + &[], + ); + let response = post_setup(&addr, r#"{"useOffer":true}"#); + let written = std::fs::read_to_string(data.join("codetrial.env.local")).unwrap_or_default(); + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(data.join("codetrial.env.local")) + .map(|meta| meta.permissions().mode() & 0o777) + .unwrap_or(0) + }; + drop(server); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&data); + + assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}"); + assert_eq!( + written, + format!("LIVEKIT_URL={livekit_url}\nLIVEKIT_API_KEY=key\nLIVEKIT_API_SECRET=secret\n") + ); + #[cfg(unix)] + assert_eq!(mode, 0o600, "{mode:o}"); +} + +/// Exported keys are this launch's own choice, so they are written and Setup +/// is skipped whatever file a launcher offers. +#[test] +fn exported_setup_keys_skip_setup_whatever_is_offered() { + let livekit_addr = spawn_livekit_room_service("key", "secret"); + let livekit_url = format!("http://{livekit_addr}"); + let (addr, server, data, home) = launch_offering( + "data-dir-offer-exported", + "LIVEKIT_URL=wss://elsewhere.livekit.cloud\nLIVEKIT_API_KEY=other\n\ + LIVEKIT_API_SECRET=other\n", + &[ + ("LIVEKIT_URL", &livekit_url), + ("LIVEKIT_API_KEY", "key"), + ("LIVEKIT_API_SECRET", "secret"), + ], + ); + let page = get(&addr, "/"); + let written = std::fs::read_to_string(data.join("codetrial.env.local")).unwrap_or_default(); + drop(server); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&data); + + assert!( + !page.contains(r#"name="livekitUrl""#), + "Setup was served: {page}" + ); + assert_eq!( + written, + format!("LIVEKIT_URL={livekit_url}\nLIVEKIT_API_KEY=key\nLIVEKIT_API_SECRET=secret\n") + ); +} + +/// An offered file that cannot be used says why, by path, in place of the +/// offer, and using it anyway is refused with the same reason. +#[test] +fn an_unusable_offered_config_is_reported_not_used() { + let (addr, server, data, home) = launch_offering( + "data-dir-offer-partial", + "LIVEKIT_URL=wss://example.livekit.cloud\n", + &[], + ); + let offer = get(&addr, "/api/setup/offer"); + let response = post_setup(&addr, r#"{"useOffer":true}"#); + let written = data.join("codetrial.env.local").exists(); + drop(server); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&data); + + let reason = "does not set LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET"; + assert!(offer.contains(reason), "{offer}"); + assert!(response.starts_with("HTTP/1.1 400"), "{response}"); + assert!(response.contains(reason), "{response}"); + assert!(!written); +} + +/// Without `--import-config` there is nothing to use, and no offer to show. +#[test] +fn setup_without_an_offer_refuses_to_use_one() { + let dir = exe_temp_path("setup-no-offer"); + std::fs::create_dir_all(&dir).unwrap(); + let exe = binary_beside(&dir); + let (addr, server) = spawn_cold_start(&exe, &dir, &[]); + let offer = get(&addr, "/api/setup/offer"); + let response = post_setup(&addr, r#"{"useOffer":true}"#); + drop(server); + let _ = std::fs::remove_dir_all(&dir); + + assert!(offer.starts_with("HTTP/1.1 404"), "{offer}"); + assert!(response.starts_with("HTTP/1.1 400"), "{response}"); + assert!(response.contains("no config was offered"), "{response}"); +} + +#[test] +fn binary_refuses_an_import_config_without_a_data_dir() { + let (code, _, stderr) = run_cli_args(&["web", "--import-config", "cli.env"]); + assert_eq!(code, 2); + assert!( + stderr.contains("--import-config needs --data-dir"), + "{stderr}" + ); +} + /// The failure note a double-clicked binary leaves beside itself goes in the /// data directory instead: an installed app's executable folder is read-only /// or inside a bundle. diff --git a/tests/config.rs b/tests/config.rs index d3e10090..9a977b70 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -2047,3 +2047,24 @@ fn compression_requires_a_valid_pair_and_preserves_provider_defaults() { assert!(!error.invalid_entries.is_empty()); } } + +/// The message quotes the line that was refused, which is how an operator +/// finds it, not the one after it: the parser counts lines from 1 and the +/// quote is read back by that count. +#[test] +fn an_invalid_config_line_is_named_by_number_and_never_quoted() { + let path = + std::env::temp_dir().join(format!("codetrial-invalid-line-{}.env", std::process::id())); + std::fs::write( + &path, + "# comment\nLIVEKIT_URL=wss://a\n not a setting \nNEXT=1\n", + ) + .unwrap(); + let error = codetrial::config::read_config_file(&path).unwrap_err(); + let _ = std::fs::remove_file(&path); + assert_eq!( + error, + format!("{}: line 3 is not KEY=value", path.display()) + ); + assert!(!error.contains("not a setting"), "{error}"); +} diff --git a/tests/unit/web/setup.rs b/tests/unit/web/setup.rs index c5d07d91..0a20d15e 100644 --- a/tests/unit/web/setup.rs +++ b/tests/unit/web/setup.rs @@ -3,8 +3,9 @@ //! unit //! test and not an integration test: private items are in scope. -use super::host_is_loopback; +use super::{host_is_loopback, read_offer}; use axum::http::HeaderValue; +use std::path::PathBuf; fn host(value: &str) -> HeaderValue { HeaderValue::from_str(value).expect("a test Host should be a header value") @@ -255,3 +256,91 @@ fn exported_credentials_the_file_cannot_hold_are_refused() { ); } } + +fn offer_file(label: &str, text: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "codetrial-offer-{label}-{}.env", + std::process::id() + )); + std::fs::write(&path, text).unwrap(); + path +} + +/// The rotation list outranks the single key, as it does when the server reads +/// the file, and the form takes one key, so the first is used and the page is +/// told how many there were. +#[test] +fn an_offered_rotation_list_gives_its_first_key_and_says_so() { + let path = offer_file( + "rotation", + "LIVEKIT_URL=wss://example.livekit.cloud\nLIVEKIT_API_KEY=key\n\ + LIVEKIT_API_SECRET=secret\nGOOGLE_API_KEY=single\nGOOGLE_API_KEYS= first , second\n", + ); + let (submission, endpoint, note) = read_offer(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + assert_eq!(endpoint, "wss://example.livekit.cloud"); + assert_eq!(submission["googleApiKey"], "first"); + assert_eq!(submission["livekitApiSecret"], "secret"); + assert!(note.unwrap().starts_with("GOOGLE_API_KEYS lists 2 keys")); + + // A list of one is just that key, with nothing to say about the rest. + let path = offer_file( + "rotation-one", + "LIVEKIT_URL=wss://example.livekit.cloud\nLIVEKIT_API_KEY=key\n\ + LIVEKIT_API_SECRET=secret\nGOOGLE_API_KEYS=only\n", + ); + let (submission, _, note) = read_offer(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(submission["googleApiKey"], "only"); + assert_eq!(note, None); +} + +/// The reason names the file once, whichever way it could not be used, and +/// names a malformed line by its number: the reason reaches the page, and the +/// line can be a key with its `=` missing. +#[test] +fn an_unusable_offer_says_why_without_repeating_the_file() { + let missing = std::env::temp_dir().join("codetrial-offer-missing-nowhere.env"); + let error = read_offer(&missing).unwrap_err(); + assert_eq!( + error.matches("codetrial-offer-missing-nowhere.env").count(), + 1, + "{error}" + ); + + let broken = offer_file( + "broken", + "# saved by hand\nLIVEKIT_URL=wss://example.livekit.cloud\nLIVEKIT_API_SECRET the-secret\n", + ); + let error = read_offer(&broken).unwrap_err(); + let _ = std::fs::remove_file(&broken); + assert_eq!( + error, + format!("{}: line 3 is not KEY=value", broken.display()) + ); + + let unusable = offer_file( + "bad-url", + "LIVEKIT_URL=ftp://example.livekit.cloud/?token=the-token\nLIVEKIT_API_KEY=key\n\ + LIVEKIT_API_SECRET=secret\n", + ); + let error = read_offer(&unusable).unwrap_err(); + let _ = std::fs::remove_file(&unusable); + assert!(error.contains("LIVEKIT_URL must start with"), "{error}"); + assert!(!error.contains("the-token"), "{error}"); +} + +/// Only the scheme and the host reach the page: user information, a path and +/// a query are where a LiveKit URL carries a credential. +#[test] +fn an_offered_url_is_shown_as_its_scheme_and_host() { + let path = offer_file( + "credentialed-url", + "LIVEKIT_URL=wss://user:the-password@example.livekit.cloud:7880/prefix?token=the-token\n\ + LIVEKIT_API_KEY=key\nLIVEKIT_API_SECRET=secret\n", + ); + let (_, endpoint, _) = read_offer(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(endpoint, "wss://example.livekit.cloud:7880"); +}