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