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: e711cf3454
Refs: crates/lanspread-peer-cli/README.md
This commit is contained in:
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"}
|
||||
```
|
||||
@@ -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<Value>,
|
||||
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<CommandEnvelope> {
|
||||
let value = serde_json::from_str::<Value>(line).wrap_err("command must be a JSON object")?;
|
||||
parse_command_value(&value)
|
||||
}
|
||||
|
||||
pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
||||
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<String, Value>,
|
||||
field: &'static str,
|
||||
) -> eyre::Result<String> {
|
||||
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<String, Value>, field: &'static str) -> eyre::Result<u64> {
|
||||
object
|
||||
.get(field)
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| eyre::eyre!("missing integer field {field}"))
|
||||
}
|
||||
|
||||
fn game_id(object: &serde_json::Map<String, Value>) -> eyre::Result<String> {
|
||||
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<String, Value>) -> eyre::Result<bool> {
|
||||
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<FixtureSeed> {
|
||||
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<Value>, command: &str, data: Value) -> eyre::Result<String> {
|
||||
output_line(json!({
|
||||
"type": "result",
|
||||
"id": id,
|
||||
"command": command,
|
||||
"ok": true,
|
||||
"data": data,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn error_line(id: &Option<Value>, command: Option<&str>, error: &str) -> eyre::Result<String> {
|
||||
output_line(json!({
|
||||
"type": "error",
|
||||
"id": id,
|
||||
"command": command,
|
||||
"error": error,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn event_line(event: &str, data: Value) -> eyre::Result<String> {
|
||||
output_line(json!({
|
||||
"type": "event",
|
||||
"event": event,
|
||||
"data": data,
|
||||
}))
|
||||
}
|
||||
|
||||
fn output_line(value: Value) -> eyre::Result<String> {
|
||||
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::<Value>(&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"));
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
fixtures: Vec<String>,
|
||||
unrar: Option<PathBuf>,
|
||||
enable_mdns: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct JsonlWriter {
|
||||
lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl JsonlWriter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&self, line: eyre::Result<String>) {
|
||||
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<LocalPeer>,
|
||||
local_games: Vec<Game>,
|
||||
remote_games: Vec<Game>,
|
||||
active_operations: Vec<ActiveOperation>,
|
||||
game_files: HashMap<String, Vec<GameFileDescription>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct LocalPeer {
|
||||
peer_id: String,
|
||||
addr: String,
|
||||
}
|
||||
|
||||
struct SharedState {
|
||||
state: RwLock<CliState>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
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<dyn lanspread_peer::Unpacker> = 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<PeerCommand>,
|
||||
handle: &mut PeerRuntimeHandle,
|
||||
shared: Arc<SharedState>,
|
||||
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<PeerCommand>,
|
||||
handle: &mut PeerRuntimeHandle,
|
||||
shared: &Arc<SharedState>,
|
||||
) -> eyre::Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<PeerCommand>,
|
||||
shared: &SharedState,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<Vec<GameFileDescription>> {
|
||||
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<PeerEvent>,
|
||||
shared: Arc<SharedState>,
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Vec<FixtureSeed>> {
|
||||
fixtures
|
||||
.iter()
|
||||
.map(|fixture| seed_fixture_game(game_dir, fixture))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> HashSet<String> {
|
||||
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<Args> {
|
||||
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> {
|
||||
[
|
||||
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<Item = OsString>, flag: &str) -> eyre::Result<String> {
|
||||
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<Item = OsString>, flag: &str) -> eyre::Result<PathBuf> {
|
||||
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."
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user