Files
lanspread/crates/lanspread-peer-cli/src/lib.rs
T
ddidderr 09709cc008 feat(peer): stamp launcher settings on first play, add PersonaName rewrite
Some games ship a SmartSteamEmu.ini somewhere under their installed
local/ tree with a `PersonaName = ...` line that must carry the player's
configured username. They also ship account_name.txt and language.txt
files that the launcher already overwrote with the username/language.

Previously that account_name.txt/language.txt overwrite happened inside
the install transaction, so it only applied to freshly (re)installed
games — games already installed by an older build never got fixed up,
and the SmartSteamEmu.ini PersonaName line was not handled at all.

This moves all per-user setting application out of install and into a
single one-shot step performed the first time a game is played, gated by
a new per-game marker `games/<id>/launch_settings_applied` under the
state dir. On first play we search the whole local/ tree and stamp:

  - the username into the first account_name.txt,
  - the language into the first language.txt,
  - the username into the first SmartSteamEmu.ini PersonaName line,
    preserving that line's existing line ending (\n or \r\n) and its
    surrounding whitespace, leaving sibling lines untouched.

The marker only records that we *tried*: it is written unconditionally
after the first play, so a game with none of these files is still marked
done and never rescanned. Because already-installed games have no marker
yet, they are fixed up on their next play rather than only on reinstall.

To keep the marker honest across version changes, the install and update
transactions now clear it on success, so a freshly extracted local/ is
re-stamped on the next play.

Behavior changes from the user's perspective:
  - The first time you press Play after this change, your username/
    language are (re)applied to an existing install, including games you
    installed before this feature existed.
  - SmartSteamEmu.ini's PersonaName now reflects the launcher username.

Plumbing: account_name/language are removed from PeerCommand::InstallGame
/DownloadGameFiles[WithOptions] and the whole install handler chain, and
the Tauri pending_install_settings bookkeeping is gone — the launcher now
computes the values at play time in run_game and calls
lanspread_peer::apply_launch_settings_once. The headless harness gains a
`play` command exposing the same step for scripted testing.

Test Plan
  - just test: new lanspread_peer::launch_settings unit tests cover the
    PersonaName rewrite, \n/\r\n preservation, first-match search, the
    unconditional marker, and the no-op-once-applied path; a transaction
    test covers the install marker reset. Whole workspace is green.
  - just clippy clean; the change adds no new clippy warnings (incl.
    --tests).
  - S38 (new in PEER_CLI_SCENARIOS.md): host run of lanspread-peer-cli
    against the new fixture-persona/css RAR .eti (with --unrar) installs
    css, then `play css` stamps the deeply-buried CRLF PersonaName line,
    account_name.txt, and language.txt and creates the marker; a second
    `play` is a no-op even after the values are reset externally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:51:59 +02:00

362 lines
10 KiB
Rust

//! 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,
},
Play {
game_id: String,
username: String,
language: Option<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::Play { .. } => "play",
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)?,
},
"play" => CliCommand::Play {
game_id: game_id(object)?,
username: required_str(object, "username")?,
language: object
.get("language")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
},
"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"));
}
}