From dc9e13e6a1b9231e0bead0edde96f563410793ca Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 16 May 2026 18:33:18 +0200 Subject: [PATCH] feat(peer-cli): add JSONL peer test harness Agents need a way to exercise multiple peers without launching the Tauri GUI. Add `lanspread-peer-cli` as a workspace crate that starts the core peer runtime, reads JSON commands from stdin, and writes result, event, and error records as JSONL on stdout. The harness supports status, peer listing, game listing, direct connect, set-game-dir, download, install, uninstall, wait-peers, and shutdown commands. It can seed tiny fixture archives that use a fixture unpacker, or delegate real archives to an external `unrar` program when one is supplied. Add a Dockerfile, `.dockerignore`, and `just` recipes for building the binary, building the image, and running named harness containers with state and games mounted under `target/peer-cli/`. The documentation now lists the crate and the new test harness commands in the project map, with a crate-local README for the JSONL protocol. This commit depends on the non-GUI peer hooks introduced in the previous commit: startup options, local-ready events, direct connects, snapshots, and explicit post-download install policy. It does not add old-peer compatibility paths. Test Plan: - `git diff --check` - `just fmt` - `just clippy` - `just test` - `just peer-cli-build` - Not run: `just peer-cli-image` requires a Docker daemon and base image access. Depends-on: e711cf3454aeab4450144b9aa737e3f4bdbd3fc1 Refs: crates/lanspread-peer-cli/README.md --- .dockerignore | 11 + CLAUDE.md | 5 + Cargo.lock | 13 + Cargo.toml | 1 + README.md | 14 + crates/lanspread-peer-cli/Cargo.toml | 30 ++ crates/lanspread-peer-cli/Dockerfile | 17 + crates/lanspread-peer-cli/README.md | 36 ++ crates/lanspread-peer-cli/src/lib.rs | 347 +++++++++++++++++ crates/lanspread-peer-cli/src/main.rs | 541 ++++++++++++++++++++++++++ justfile | 17 + 11 files changed, 1032 insertions(+) create mode 100644 .dockerignore create mode 100644 crates/lanspread-peer-cli/Cargo.toml create mode 100644 crates/lanspread-peer-cli/Dockerfile create mode 100644 crates/lanspread-peer-cli/README.md create mode 100644 crates/lanspread-peer-cli/src/lib.rs create mode 100644 crates/lanspread-peer-cli/src/main.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1b90caa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.agents +.codex +target +**/target +**/node_modules +**/dist +**/.vite +**/.deno +**/.cache +target/peer-cli diff --git a/CLAUDE.md b/CLAUDE.md index 1bb785b..5387ac3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ Cargo workspace under `crates/`: - `lanspread-db` — database/schema types (sqlx + sqlite). - `lanspread-compat` — compatibility/migration glue between db and other crates. - `lanspread-utils` — small shared helpers. +- `lanspread-peer-cli` — JSONL peer harness for scripted and containerized tests. - `lanspread-tauri-deno-ts/` — frontend (Vite + Deno + TS in `src/`) and Tauri shell (`src-tauri/`). This is the GUI client. Top-level `Cargo.toml` pins workspace dependency versions; per-crate `Cargo.toml`s set lints (pedantic clippy, `unsafe_code = forbid` on most). @@ -24,8 +25,12 @@ Never use normal cargo ... commands, use the just ... commands instead. - `just build` — build the GUI without bundling (also dev mode). - `just fmt` — format the workspace. - `just clippy` — lint the workspace. +- `just test` — run the workspace unit tests. - `just fix` — auto-apply cargo/clippy fixes, then format. - `just clean` — wipe the build cache. +- `just peer-cli-build` — build the scripted peer harness. +- `just peer-cli-image` — build the peer harness Docker image. +- `just peer-cli-run NAME` — run one named harness container with persistent state under `target/peer-cli/NAME/`. ## Protocol policy diff --git a/Cargo.lock b/Cargo.lock index a843d1a..31973e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2192,6 +2192,19 @@ dependencies = [ "walkdir", ] +[[package]] +name = "lanspread-peer-cli" +version = "0.1.0" +dependencies = [ + "eyre", + "lanspread-compat", + "lanspread-db", + "lanspread-peer", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "lanspread-proto" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7544488..ffb55d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/lanspread-db", "crates/lanspread-mdns", "crates/lanspread-peer", + "crates/lanspread-peer-cli", "crates/lanspread-proto", "crates/lanspread-tauri-deno-ts/src-tauri", "crates/lanspread-utils", diff --git a/README.md b/README.md index 53f3f22..c9a9cf1 100644 --- a/README.md +++ b/README.md @@ -29,4 +29,18 @@ just build # run just run + +# test +just test +``` + +### Scripted peer harness + +`crates/lanspread-peer-cli` runs the peer runtime without the GUI and speaks +JSONL on stdin/stdout. It is intended for automated multi-peer smoke tests. + +```bash +just peer-cli-build +just peer-cli-image +just peer-cli-run alpha ``` diff --git a/crates/lanspread-peer-cli/Cargo.toml b/crates/lanspread-peer-cli/Cargo.toml new file mode 100644 index 0000000..ef1b11a --- /dev/null +++ b/crates/lanspread-peer-cli/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "lanspread-peer-cli" +version = "0.1.0" +edition = "2024" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +todo = "warn" +unwrap_used = "warn" +needless_pass_by_value = "allow" + +[dependencies] +lanspread-compat = { path = "../lanspread-compat" } +lanspread-db = { path = "../lanspread-db" } +lanspread-peer = { path = "../lanspread-peer" } + +eyre = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } + +[lib] +doctest = false + +[[bin]] +name = "lanspread-peer-cli" +path = "src/main.rs" diff --git a/crates/lanspread-peer-cli/Dockerfile b/crates/lanspread-peer-cli/Dockerfile new file mode 100644 index 0000000..ca24bfc --- /dev/null +++ b/crates/lanspread-peer-cli/Dockerfile @@ -0,0 +1,17 @@ +FROM rust:1-bookworm AS build + +WORKDIR /work +COPY . . +RUN cargo build --release -p lanspread-peer-cli + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /work/target/release/lanspread-peer-cli /usr/local/bin/lanspread-peer-cli +COPY crates/lanspread-tauri-deno-ts/src-tauri/game.db /app/game.db + +ENTRYPOINT ["lanspread-peer-cli"] +CMD ["--games-dir", "/games", "--state-dir", "/state", "--catalog-db", "/app/game.db"] diff --git a/crates/lanspread-peer-cli/README.md b/crates/lanspread-peer-cli/README.md new file mode 100644 index 0000000..a1f87b9 --- /dev/null +++ b/crates/lanspread-peer-cli/README.md @@ -0,0 +1,36 @@ +# lanspread-peer-cli + +Scriptable peer harness for automated LAN-spread tests. The binary starts the +core peer runtime without the Tauri GUI, reads one JSON command per stdin line, +and writes JSONL events, results, and errors to stdout. + +## Running + +```bash +just peer-cli-build +just peer-cli-image +just peer-cli-run alpha +``` + +Useful flags: + +- `--games-dir PATH` stores local archives and installs. +- `--state-dir PATH` stores the generated peer identity. +- `--fixture GAME_ID` seeds a tiny archive that the fixture unpacker can install. +- `--no-mdns` disables mDNS so tests can use explicit `connect` commands. + +## Commands + +Every command is a JSON object with `cmd` or `command`; `id` is optional and is +echoed back on the result or error line. + +```json +{"id":"s1","cmd":"status"} +{"id":"p1","cmd":"wait-peers","count":1,"timeout_ms":5000} +{"id":"c1","cmd":"connect","addr":"127.0.0.1:34567"} +{"id":"g1","cmd":"list-games"} +{"id":"d1","cmd":"download","game_id":"fixture-one","install":true} +{"id":"i1","cmd":"install","game_id":"fixture-one"} +{"id":"u1","cmd":"uninstall","game_id":"fixture-one"} +{"id":"q1","cmd":"shutdown"} +``` diff --git a/crates/lanspread-peer-cli/src/lib.rs b/crates/lanspread-peer-cli/src/lib.rs new file mode 100644 index 0000000..72746ba --- /dev/null +++ b/crates/lanspread-peer-cli/src/lib.rs @@ -0,0 +1,347 @@ +//! Shared parser, fixture, and JSONL helpers for the scripted peer harness. + +#![allow(clippy::missing_errors_doc)] + +use std::{ + net::SocketAddr, + path::{Path, PathBuf}, + time::Duration, +}; + +use eyre::{Context, OptionExt}; +use lanspread_peer::{UnpackFuture, Unpacker}; +use serde::Serialize; +use serde_json::{Value, json}; + +pub const DEFAULT_FIXTURE_VERSION: &str = "20250101"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandEnvelope { + pub request_id: Option, + pub command: CliCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CliCommand { + Status, + ListPeers, + ListGames, + SetGameDir { + path: PathBuf, + }, + Download { + game_id: String, + install_after_download: bool, + }, + Install { + game_id: String, + }, + Uninstall { + game_id: String, + }, + WaitPeers { + count: usize, + timeout: Duration, + }, + Connect { + addr: SocketAddr, + }, + Shutdown, +} + +impl CliCommand { + #[must_use] + pub fn name(&self) -> &'static str { + match self { + Self::Status => "status", + Self::ListPeers => "list-peers", + Self::ListGames => "list-games", + Self::SetGameDir { .. } => "set-game-dir", + Self::Download { .. } => "download", + Self::Install { .. } => "install", + Self::Uninstall { .. } => "uninstall", + Self::WaitPeers { .. } => "wait-peers", + Self::Connect { .. } => "connect", + Self::Shutdown => "shutdown", + } + } +} + +pub fn parse_command_line(line: &str) -> eyre::Result { + let value = serde_json::from_str::(line).wrap_err("command must be a JSON object")?; + parse_command_value(&value) +} + +pub fn parse_command_value(value: &Value) -> eyre::Result { + let object = value + .as_object() + .ok_or_eyre("command must be a JSON object")?; + let request_id = object.get("id").cloned(); + let command_name = object + .get("cmd") + .or_else(|| object.get("command")) + .and_then(Value::as_str) + .ok_or_eyre("command object must include a string cmd")?; + let command_name = command_name.replace('_', "-"); + + let command = match command_name.as_str() { + "status" => CliCommand::Status, + "list-peers" => CliCommand::ListPeers, + "list-games" => CliCommand::ListGames, + "set-game-dir" => CliCommand::SetGameDir { + path: PathBuf::from(required_str(object, "path")?), + }, + "download" => CliCommand::Download { + game_id: game_id(object)?, + install_after_download: install_after_download(object)?, + }, + "install" => CliCommand::Install { + game_id: game_id(object)?, + }, + "uninstall" => CliCommand::Uninstall { + game_id: game_id(object)?, + }, + "wait-peers" => CliCommand::WaitPeers { + count: required_u64(object, "count")? + .try_into() + .wrap_err("count does not fit in usize")?, + timeout: Duration::from_millis(required_u64(object, "timeout_ms")?), + }, + "connect" | "direct-connect" => CliCommand::Connect { + addr: required_str(object, "addr")? + .parse() + .wrap_err("addr must be a socket address like 127.0.0.1:12345")?, + }, + "shutdown" => CliCommand::Shutdown, + other => eyre::bail!("unknown command: {other}"), + }; + + Ok(CommandEnvelope { + request_id, + command, + }) +} + +fn required_str( + object: &serde_json::Map, + field: &'static str, +) -> eyre::Result { + object + .get(field) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| eyre::eyre!("missing string field {field}")) +} + +fn required_u64(object: &serde_json::Map, field: &'static str) -> eyre::Result { + object + .get(field) + .and_then(Value::as_u64) + .ok_or_else(|| eyre::eyre!("missing integer field {field}")) +} + +fn game_id(object: &serde_json::Map) -> eyre::Result { + object + .get("game_id") + .or_else(|| object.get("game")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_eyre("game commands must include string game_id") +} + +fn install_after_download(object: &serde_json::Map) -> eyre::Result { + if let Some(value) = object.get("install") { + return value + .as_bool() + .ok_or_eyre("install must be boolean when provided"); + } + + if let Some(value) = object.get("no_install") { + return value + .as_bool() + .map(|no_install| !no_install) + .ok_or_eyre("no_install must be boolean when provided"); + } + + Ok(true) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FixtureSeed { + pub game_id: String, + pub game_root: PathBuf, + pub archive: PathBuf, +} + +pub fn seed_fixture_game(game_dir: &Path, fixture_name: &str) -> eyre::Result { + let game_id = fixture_name.to_string(); + let game_root = game_dir.join(&game_id); + std::fs::create_dir_all(&game_root)?; + + let version_path = game_root.join("version.ini"); + std::fs::write(version_path, DEFAULT_FIXTURE_VERSION.as_bytes())?; + + let archive = game_root.join(format!("{game_id}.eti")); + std::fs::write( + &archive, + format!("fixture archive for {game_id}\n").as_bytes(), + )?; + + Ok(FixtureSeed { + game_id, + game_root, + archive, + }) +} + +pub struct FixtureUnpacker; + +impl Unpacker for FixtureUnpacker { + fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { + Box::pin(async move { + tokio::fs::create_dir_all(dest).await?; + let payload = tokio::fs::read(archive).await?; + tokio::fs::write(dest.join("fixture-payload.txt"), payload).await?; + Ok(()) + }) + } +} + +pub struct ExternalUnrarUnpacker { + program: PathBuf, +} + +impl ExternalUnrarUnpacker { + #[must_use] + pub fn new(program: PathBuf) -> Self { + Self { program } + } +} + +impl Unpacker for ExternalUnrarUnpacker { + fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { + Box::pin(async move { + tokio::fs::create_dir_all(dest).await?; + let status = tokio::process::Command::new(&self.program) + .arg("x") + .arg("-o+") + .arg(archive) + .arg(dest) + .status() + .await?; + if !status.success() { + eyre::bail!( + "unrar failed for {} with status {status}", + archive.display() + ); + } + Ok(()) + }) + } +} + +pub fn result_line(id: &Option, command: &str, data: Value) -> eyre::Result { + output_line(json!({ + "type": "result", + "id": id, + "command": command, + "ok": true, + "data": data, + })) +} + +pub fn error_line(id: &Option, command: Option<&str>, error: &str) -> eyre::Result { + output_line(json!({ + "type": "error", + "id": id, + "command": command, + "error": error, + })) +} + +pub fn event_line(event: &str, data: Value) -> eyre::Result { + output_line(json!({ + "type": "event", + "event": event, + "data": data, + })) +} + +fn output_line(value: Value) -> eyre::Result { + serde_json::to_string(&value).wrap_err("failed to serialize JSONL output") +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + struct TempDir(PathBuf); + + impl TempDir { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "{name}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("temp dir should be created"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn parses_download_command_with_correlation_id() { + let parsed = parse_command_line( + r#"{"id":"req-1","cmd":"download","game_id":"fixture-one","install":false}"#, + ) + .expect("command should parse"); + + assert_eq!(parsed.request_id, Some(json!("req-1"))); + assert_eq!( + parsed.command, + CliCommand::Download { + game_id: "fixture-one".to_string(), + install_after_download: false, + } + ); + } + + #[test] + fn formats_result_lines() { + let line = result_line(&Some(json!(7)), "status", json!({"peer_count": 0})) + .expect("line should serialize"); + let parsed = serde_json::from_str::(&line).expect("line should be json"); + assert_eq!(parsed["type"], "result"); + assert_eq!(parsed["id"], 7); + assert_eq!(parsed["data"]["peer_count"], 0); + } + + #[tokio::test] + async fn fixture_unpacker_creates_install_payload() { + let temp = TempDir::new("lanspread-peer-cli-fixture"); + let seed = seed_fixture_game(temp.path(), "fixture-one").expect("fixture should seed"); + let dest = temp.path().join("staging"); + Arc::new(FixtureUnpacker) + .unpack(&seed.archive, &dest) + .await + .expect("fixture archive should unpack"); + + let payload = std::fs::read_to_string(dest.join("fixture-payload.txt")) + .expect("payload should be written"); + assert!(payload.contains("fixture-one")); + } +} diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs new file mode 100644 index 0000000..130fa45 --- /dev/null +++ b/crates/lanspread-peer-cli/src/main.rs @@ -0,0 +1,541 @@ +//! JSONL command-line harness for running a peer without the Tauri GUI. + +use std::{ + collections::{HashMap, HashSet}, + ffi::OsString, + io::Write as _, + net::SocketAddr, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use eyre::Context; +use lanspread_compat::eti::get_games; +use lanspread_db::db::{Game, GameFileDescription}; +use lanspread_peer::{ + ActiveOperation, + ActiveOperationKind, + InstallOperation, + PeerCommand, + PeerEvent, + PeerGameDB, + PeerRuntimeComponent, + PeerRuntimeHandle, + PeerSnapshot, + PeerStartOptions, + start_peer_with_options, +}; +use lanspread_peer_cli::{ + CliCommand, + CommandEnvelope, + ExternalUnrarUnpacker, + FixtureSeed, + FixtureUnpacker, + error_line, + event_line, + parse_command_line, + result_line, + seed_fixture_game, +}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + sync::{Notify, RwLock, mpsc}, +}; + +#[derive(Debug)] +struct Args { + name: String, + games_dir: PathBuf, + state_dir: PathBuf, + catalog_db: Option, + fixtures: Vec, + unrar: Option, + enable_mdns: bool, +} + +#[derive(Clone)] +struct JsonlWriter { + lock: Arc>, +} + +impl JsonlWriter { + fn new() -> Self { + Self { + lock: Arc::new(Mutex::new(())), + } + } + + fn emit(&self, line: eyre::Result) { + let line = match line { + Ok(line) => line, + Err(err) => { + eprintln!("failed to prepare JSONL output: {err}"); + return; + } + }; + + let Ok(_guard) = self.lock.lock() else { + eprintln!("failed to lock stdout"); + return; + }; + + let mut stdout = std::io::stdout().lock(); + if let Err(err) = writeln!(stdout, "{line}") { + eprintln!("failed to write JSONL output: {err}"); + } + } +} + +#[derive(Default)] +struct CliState { + local_peer: Option, + local_games: Vec, + remote_games: Vec, + active_operations: Vec, + game_files: HashMap>, +} + +#[derive(Clone, serde::Serialize)] +struct LocalPeer { + peer_id: String, + addr: String, +} + +struct SharedState { + state: RwLock, + peer_game_db: Arc>, + notify: Notify, +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + let args = parse_args()?; + tokio::fs::create_dir_all(&args.games_dir).await?; + tokio::fs::create_dir_all(&args.state_dir).await?; + + let fixture_seeds = seed_fixtures(&args.games_dir, &args.fixtures)?; + let catalog = load_catalog(args.catalog_db.as_deref(), &fixture_seeds).await; + + let (tx_events, rx_events) = mpsc::unbounded_channel(); + let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); + let catalog = Arc::new(RwLock::new(catalog)); + let unpacker: Arc = match args.unrar { + Some(path) => Arc::new(ExternalUnrarUnpacker::new(path)), + None => Arc::new(FixtureUnpacker), + }; + + let mut handle = start_peer_with_options( + args.games_dir.clone(), + tx_events, + peer_game_db.clone(), + unpacker, + catalog, + PeerStartOptions { + state_dir: Some(args.state_dir.clone()), + enable_mdns: args.enable_mdns, + }, + )?; + let sender = handle.sender(); + + let shared = Arc::new(SharedState { + state: RwLock::new(CliState::default()), + peer_game_db, + notify: Notify::new(), + }); + let writer = JsonlWriter::new(); + + tokio::spawn(event_loop(rx_events, shared.clone(), writer.clone())); + + writer.emit(event_line( + "cli-started", + json!({ + "name": args.name, + "games_dir": args.games_dir, + "state_dir": args.state_dir, + "fixtures": fixture_seeds, + "mdns": args.enable_mdns, + }), + )); + + command_loop(&sender, &mut handle, shared, writer).await +} + +async fn command_loop( + sender: &mpsc::UnboundedSender, + handle: &mut PeerRuntimeHandle, + shared: Arc, + writer: JsonlWriter, +) -> eyre::Result<()> { + let stdin = BufReader::new(tokio::io::stdin()); + let mut lines = stdin.lines(); + + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + + let envelope = match parse_command_line(&line) { + Ok(envelope) => envelope, + Err(err) => { + writer.emit(error_line(&None, None, &err.to_string())); + continue; + } + }; + + let command_name = envelope.command.name(); + match handle_command(&envelope, sender, handle, &shared).await { + Ok(data) => { + writer.emit(result_line(&envelope.request_id, command_name, data)); + if matches!(envelope.command, CliCommand::Shutdown) { + break; + } + } + Err(err) => { + writer.emit(error_line( + &envelope.request_id, + Some(command_name), + &err.to_string(), + )); + } + } + } + + Ok(()) +} + +async fn handle_command( + envelope: &CommandEnvelope, + sender: &mpsc::UnboundedSender, + handle: &mut PeerRuntimeHandle, + shared: &Arc, +) -> eyre::Result { + match &envelope.command { + CliCommand::Status => status(shared).await, + CliCommand::ListPeers => list_peers(shared).await, + CliCommand::ListGames => list_games(shared).await, + CliCommand::SetGameDir { path } => { + sender.send(PeerCommand::SetGameDir(path.clone()))?; + Ok(json!({"queued": true, "path": path})) + } + CliCommand::Download { + game_id, + install_after_download, + } => { + let files = game_files_for_download(sender, shared, game_id).await?; + sender.send(PeerCommand::DownloadGameFilesWithOptions { + id: game_id.clone(), + file_descriptions: files, + install_after_download: *install_after_download, + })?; + Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download})) + } + CliCommand::Install { game_id } => { + sender.send(PeerCommand::InstallGame { + id: game_id.clone(), + })?; + Ok(json!({"queued": true, "game_id": game_id})) + } + CliCommand::Uninstall { game_id } => { + sender.send(PeerCommand::UninstallGame { + id: game_id.clone(), + })?; + Ok(json!({"queued": true, "game_id": game_id})) + } + CliCommand::WaitPeers { count, timeout } => wait_peers(shared, *count, *timeout).await, + CliCommand::Connect { addr } => { + sender.send(PeerCommand::ConnectPeer(*addr))?; + Ok(json!({"queued": true, "addr": addr.to_string()})) + } + CliCommand::Shutdown => { + handle.shutdown(); + tokio::time::timeout(Duration::from_secs(5), handle.wait_stopped()) + .await + .wrap_err("timed out waiting for peer runtime shutdown")?; + Ok(json!({"stopped": true})) + } + } +} + +async fn status(shared: &SharedState) -> eyre::Result { + let state = shared.state.read().await; + let peer_count = shared.peer_game_db.read().await.peer_snapshots().len(); + Ok(json!({ + "local_peer": state.local_peer.clone(), + "peer_count": peer_count, + "local_games": state.local_games.len(), + "remote_games": state.remote_games.len(), + "active_operations": active_operations_json(&state.active_operations), + })) +} + +async fn list_peers(shared: &SharedState) -> eyre::Result { + let peers = shared.peer_game_db.read().await.peer_snapshots(); + Ok(json!({ "peers": peer_snapshots_json(&peers) })) +} + +async fn list_games(shared: &SharedState) -> eyre::Result { + let state = shared.state.read().await; + let remote = shared.peer_game_db.read().await.get_all_games(); + Ok(json!({ + "local": state.local_games.clone(), + "remote": remote, + "active_operations": active_operations_json(&state.active_operations), + })) +} + +async fn wait_peers(shared: &SharedState, count: usize, timeout: Duration) -> eyre::Result { + let wait = async { + loop { + let peer_count = shared.peer_game_db.read().await.peer_snapshots().len(); + if peer_count >= count { + return peer_count; + } + shared.notify.notified().await; + } + }; + + let peer_count = tokio::time::timeout(timeout, wait) + .await + .wrap_err("timed out waiting for peers")?; + Ok(json!({"peer_count": peer_count})) +} + +async fn game_files_for_download( + sender: &mpsc::UnboundedSender, + shared: &SharedState, + game_id: &str, +) -> eyre::Result> { + if let Some(files) = shared.state.read().await.game_files.get(game_id).cloned() { + return Ok(files); + } + + sender.send(PeerCommand::GetGame(game_id.to_string()))?; + let wait = async { + loop { + if let Some(files) = shared.state.read().await.game_files.get(game_id).cloned() { + return files; + } + shared.notify.notified().await; + } + }; + + tokio::time::timeout(Duration::from_secs(10), wait) + .await + .wrap_err("timed out waiting for game file details") +} + +async fn event_loop( + mut rx_events: mpsc::UnboundedReceiver, + shared: Arc, + writer: JsonlWriter, +) { + while let Some(event) = rx_events.recv().await { + let (event_name, data) = update_state_from_event(&shared, event).await; + writer.emit(event_line(event_name, data)); + shared.notify.notify_waiters(); + } +} + +async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'static str, Value) { + match event { + PeerEvent::LocalPeerReady { peer_id, addr } => { + let local_peer = LocalPeer { + peer_id, + addr: addr.to_string(), + }; + shared.state.write().await.local_peer = Some(local_peer.clone()); + ("local-peer-ready", json!(local_peer)) + } + PeerEvent::ListGames(games) => { + shared.state.write().await.remote_games = games.clone(); + ("list-games", json!({ "games": games })) + } + PeerEvent::LocalGamesUpdated { + games, + active_operations, + } => { + let mut state = shared.state.write().await; + state.local_games.clone_from(&games); + state.active_operations.clone_from(&active_operations); + ( + "local-games-updated", + json!({ + "games": games, + "active_operations": active_operations_json(&active_operations), + }), + ) + } + PeerEvent::GotGameFiles { + id, + file_descriptions, + } => { + shared + .state + .write() + .await + .game_files + .insert(id.clone(), file_descriptions.clone()); + ( + "got-game-files", + json!({"game_id": id, "file_descriptions": file_descriptions}), + ) + } + PeerEvent::DownloadGameFilesBegin { id } => ("download-begin", json!({"game_id": id})), + PeerEvent::DownloadGameFilesFinished { id } => { + ("download-finished", json!({"game_id": id})) + } + PeerEvent::DownloadGameFilesFailed { id } => ("download-failed", json!({"game_id": id})), + PeerEvent::DownloadGameFilesAllPeersGone { id } => { + ("download-peers-gone", json!({"game_id": id})) + } + PeerEvent::InstallGameBegin { id, operation } => ( + "install-begin", + json!({"game_id": id, "operation": install_operation_name(operation)}), + ), + PeerEvent::InstallGameFinished { id } => ("install-finished", json!({"game_id": id})), + PeerEvent::InstallGameFailed { id } => ("install-failed", json!({"game_id": id})), + PeerEvent::UninstallGameBegin { id } => ("uninstall-begin", json!({"game_id": id})), + PeerEvent::UninstallGameFinished { id } => ("uninstall-finished", json!({"game_id": id})), + PeerEvent::UninstallGameFailed { id } => ("uninstall-failed", json!({"game_id": id})), + PeerEvent::NoPeersHaveGame { id } => ("no-peers-have-game", json!({"game_id": id})), + PeerEvent::PeerConnected(addr) => ("peer-connected", peer_addr_json(addr)), + PeerEvent::PeerDisconnected(addr) => ("peer-disconnected", peer_addr_json(addr)), + PeerEvent::PeerDiscovered(addr) => ("peer-discovered", peer_addr_json(addr)), + PeerEvent::PeerLost(addr) => ("peer-lost", peer_addr_json(addr)), + PeerEvent::PeerCountUpdated(count) => ("peer-count-updated", json!({"count": count})), + PeerEvent::RuntimeFailed { component, error } => ( + "runtime-failed", + json!({"component": runtime_component_name(component), "error": error}), + ), + } +} + +fn peer_addr_json(addr: SocketAddr) -> Value { + json!({"addr": addr.to_string()}) +} + +fn active_operations_json(active_operations: &[ActiveOperation]) -> Vec { + active_operations + .iter() + .map(|operation| { + json!({ + "game_id": operation.id.clone(), + "operation": active_operation_name(operation.operation), + }) + }) + .collect() +} + +fn peer_snapshots_json(peers: &[PeerSnapshot]) -> Vec { + peers + .iter() + .map(|peer| { + json!({ + "peer_id": peer.peer_id.clone(), + "addr": peer.addr.to_string(), + "library_rev": peer.library_rev, + "library_digest": peer.library_digest, + "features": peer.features.clone(), + "game_count": peer.game_count, + "games": peer.games.clone(), + }) + }) + .collect() +} + +fn active_operation_name(operation: ActiveOperationKind) -> &'static str { + (&operation).into() +} + +fn install_operation_name(operation: InstallOperation) -> &'static str { + (&operation).into() +} + +fn runtime_component_name(component: PeerRuntimeComponent) -> &'static str { + (&component).into() +} + +fn seed_fixtures(game_dir: &Path, fixtures: &[String]) -> eyre::Result> { + fixtures + .iter() + .map(|fixture| seed_fixture_game(game_dir, fixture)) + .collect() +} + +async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> HashSet { + let mut catalog = HashSet::new(); + if let Some(path) = catalog_db + && path.exists() + { + match get_games(path).await { + Ok(games) => catalog.extend(games.into_iter().map(|game| game.game_id)), + Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()), + } + } + + catalog.extend(fixtures.iter().map(|seed| seed.game_id.clone())); + catalog +} + +fn parse_args() -> eyre::Result { + let mut args = std::env::args_os().skip(1); + let mut parsed = Args { + name: "peer".to_string(), + games_dir: PathBuf::from("games"), + state_dir: PathBuf::from("state"), + catalog_db: default_catalog_db(), + fixtures: Vec::new(), + unrar: None, + enable_mdns: true, + }; + + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--help" | "-h") => { + print_help(); + std::process::exit(0); + } + Some("--name") => parsed.name = next_string(&mut args, "--name")?, + Some("--games-dir") => parsed.games_dir = next_path(&mut args, "--games-dir")?, + Some("--state-dir") => parsed.state_dir = next_path(&mut args, "--state-dir")?, + Some("--catalog-db") => parsed.catalog_db = Some(next_path(&mut args, "--catalog-db")?), + Some("--fixture") => parsed.fixtures.push(next_string(&mut args, "--fixture")?), + Some("--unrar") => parsed.unrar = Some(next_path(&mut args, "--unrar")?), + Some("--no-mdns") => parsed.enable_mdns = false, + Some(other) => eyre::bail!("unknown argument: {other}"), + None => eyre::bail!("argument is not valid UTF-8: {arg:?}"), + } + } + + Ok(parsed) +} + +fn default_catalog_db() -> Option { + [ + PathBuf::from("/app/game.db"), + PathBuf::from("crates/lanspread-tauri-deno-ts/src-tauri/game.db"), + ] + .into_iter() + .find(|path| path.exists()) +} + +fn next_string(args: &mut impl Iterator, flag: &str) -> eyre::Result { + args.next() + .ok_or_else(|| eyre::eyre!("{flag} requires a value"))? + .into_string() + .map_err(|value| eyre::eyre!("{flag} value is not valid UTF-8: {value:?}")) +} + +fn next_path(args: &mut impl Iterator, flag: &str) -> eyre::Result { + Ok(PathBuf::from(next_string(args, flag)?)) +} + +fn print_help() { + eprintln!( + "usage: lanspread-peer-cli [--name NAME] [--games-dir PATH] [--state-dir PATH] \\ + [--catalog-db PATH] [--fixture GAME_ID] [--unrar PATH] [--no-mdns]\n\ + Reads JSONL commands on stdin and writes result/event/error JSONL on stdout." + ); +} diff --git a/justfile b/justfile index 74895c2..6a0621e 100644 --- a/justfile +++ b/justfile @@ -24,3 +24,20 @@ test: clean: cargo clean + +peer-cli-build: + cargo build -p lanspread-peer-cli + +peer-cli-image: + docker build -f crates/lanspread-peer-cli/Dockerfile -t lanspread-peer-cli:dev . + +peer-cli-run NAME: + mkdir -p "target/peer-cli/{{NAME}}/state" "target/peer-cli/{{NAME}}/games" + docker run --rm --init --network host --name "lanspread-peer-cli-{{NAME}}" -i \ + -v "$PWD/target/peer-cli/{{NAME}}/state:/state" \ + -v "$PWD/target/peer-cli/{{NAME}}/games:/games" \ + lanspread-peer-cli:dev \ + --name "{{NAME}}" \ + --games-dir /games \ + --state-dir /state \ + --catalog-db /app/game.db