Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
18f21bdf30
|
|||
|
09709cc008
|
@@ -45,6 +45,7 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
|
||||
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
|
||||
| S36 | Latest singleton beats stale majority | Five peers advertise one game; one peer has `20260501`, four peers have `20250101`. | `list-games` reports `eti_game_version=20260501`; all descriptors and chunks come from the singleton latest peer; stale peers contribute zero bytes. |
|
||||
| S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. |
|
||||
| S38 | First-play launch-setting stamping | `fixture-persona/css` ships a real RAR `.eti` whose tree buries a CRLF `SmartSteamEmu.ini` with a stub `PersonaName` line under `engine/bin/win64/steam_settings/`, plus a stub `account_name.txt` and `language.txt` under `profiles/local/`. A peer installs `css` (with `--unrar`), then sends `play css` with a username and language, then `play css` again. | After install the marker `games/css/launch_settings_applied` is absent and the stub files are intact under `local/`. The first `play` returns `already_applied=false` with `account_name_written`, `language_written`, and `persona_name_written` all true; the deep `SmartSteamEmu.ini` `PersonaName` value becomes the username with its `\r\n` ending and sibling lines preserved, `account_name.txt` becomes the username, `language.txt` becomes the passed language, and the marker now exists. A second `play` returns `already_applied=true`, rewrites nothing, and leaves the files untouched even if their values were reset externally. |
|
||||
|
||||
## Version-Skew Contract
|
||||
|
||||
@@ -91,8 +92,44 @@ different `version.ini` contents. The existing alpha/bravo/charlie fixtures
|
||||
cover duplicate-source and shared-game cases, but not the three-version skew
|
||||
until a dedicated fixture or temporary games root is prepared.
|
||||
|
||||
## First-Play Launch-Setting Contract
|
||||
|
||||
Use S38 to pin down how launcher settings are stamped into an installed game:
|
||||
|
||||
- Stamping happens on the first `play`, not during install/update. The install
|
||||
transaction only clears the `games/<id>/launch_settings_applied` marker so the
|
||||
next play reapplies settings to a freshly (re)created `local/`.
|
||||
- The first play stamps the username into the first `account_name.txt` and the
|
||||
first `SmartSteamEmu.ini` `PersonaName` line, and the language into the first
|
||||
`language.txt`, searching the whole `local/` tree. The matched `PersonaName`
|
||||
line keeps its existing line ending (`\n` or `\r\n`).
|
||||
- The marker records only that we *tried*: it is written unconditionally after
|
||||
the first play, so a game with none of these files is still marked done.
|
||||
- S38 needs a real archive expanded with `--unrar`, so it runs against the host
|
||||
`lanspread-peer-cli` binary rather than the Docker matrix image (which omits
|
||||
`unrar`). The peer crate's `launch_settings` unit tests cover the rewrite,
|
||||
line-ending, and marker logic deterministically.
|
||||
|
||||
## Run Log
|
||||
|
||||
### 2026-05-28 - First-Play Launch-Setting Stamping (S38)
|
||||
|
||||
- Code under test moved the `account_name.txt`/`language.txt` overwrite out of
|
||||
the install transaction and into a single first-play step (shared with the new
|
||||
`SmartSteamEmu.ini` `PersonaName` rewrite) gated by the
|
||||
`games/<id>/launch_settings_applied` marker.
|
||||
- `just test` passed the whole workspace, including the new
|
||||
`lanspread_peer::launch_settings` unit tests and
|
||||
`install::transaction::install_resets_launch_settings_marker`.
|
||||
- S38 host run: built `crates/lanspread-peer-cli/fixtures/fixture-persona/css`
|
||||
with a stored RAR `.eti` (verified by `unrar t`) burying a CRLF
|
||||
`SmartSteamEmu.ini` plus stub `account_name.txt`/`language.txt`. A host peer
|
||||
installed `css` with `--unrar /usr/bin/unrar`, then `play css` stamped the
|
||||
username into the deep `PersonaName` line (CRLF preserved, sibling lines
|
||||
intact) and `account_name.txt`, the language into `language.txt`, and created
|
||||
the marker. A second `play css` returned `already_applied=true` and rewrote
|
||||
nothing even after the value was reset externally.
|
||||
|
||||
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
|
||||
|
||||
- Code under test included `5c4976d` (`fix(peer): settle local state before
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
20250101
|
||||
@@ -39,6 +39,11 @@ pub enum CliCommand {
|
||||
Uninstall {
|
||||
game_id: String,
|
||||
},
|
||||
Play {
|
||||
game_id: String,
|
||||
username: String,
|
||||
language: Option<String>,
|
||||
},
|
||||
WaitPeers {
|
||||
count: usize,
|
||||
timeout: Duration,
|
||||
@@ -60,6 +65,7 @@ impl CliCommand {
|
||||
Self::Download { .. } => "download",
|
||||
Self::Install { .. } => "install",
|
||||
Self::Uninstall { .. } => "uninstall",
|
||||
Self::Play { .. } => "play",
|
||||
Self::WaitPeers { .. } => "wait-peers",
|
||||
Self::Connect { .. } => "connect",
|
||||
Self::Shutdown => "shutdown",
|
||||
@@ -101,6 +107,14 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
||||
"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()
|
||||
|
||||
@@ -116,6 +116,8 @@ struct SharedState {
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
notify: Notify,
|
||||
games_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -153,6 +155,8 @@ async fn main() -> eyre::Result<()> {
|
||||
peer_game_db,
|
||||
catalog: catalog.clone(),
|
||||
notify: Notify::new(),
|
||||
games_dir: args.games_dir.clone(),
|
||||
state_dir: args.state_dir.clone(),
|
||||
});
|
||||
let writer = JsonlWriter::new();
|
||||
|
||||
@@ -240,8 +244,6 @@ async fn handle_command(
|
||||
id: game_id.clone(),
|
||||
file_descriptions: files,
|
||||
install_after_download: *install_after_download,
|
||||
account_name: None,
|
||||
language: None,
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
||||
}
|
||||
@@ -250,11 +252,14 @@ async fn handle_command(
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
sender.send(PeerCommand::InstallGame {
|
||||
id: game_id.clone(),
|
||||
account_name: None,
|
||||
language: None,
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id}))
|
||||
}
|
||||
CliCommand::Play {
|
||||
game_id,
|
||||
username,
|
||||
language,
|
||||
} => play(shared, game_id, username, language.as_deref()).await,
|
||||
CliCommand::Uninstall { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
@@ -314,6 +319,25 @@ async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn play(
|
||||
shared: &SharedState,
|
||||
game_id: &str,
|
||||
username: &str,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<Value> {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
let game_root = shared.games_dir.join(game_id);
|
||||
let outcome = lanspread_peer::apply_launch_settings_once(
|
||||
&shared.state_dir,
|
||||
&game_root,
|
||||
game_id,
|
||||
Some(username),
|
||||
language,
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({ "game_id": game_id, "outcome": outcome }))
|
||||
}
|
||||
|
||||
async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
|
||||
if shared.catalog.read().await.contains(game_id) {
|
||||
return Ok(());
|
||||
|
||||
@@ -199,8 +199,6 @@ pub async fn handle_download_game_files_command(
|
||||
id: String,
|
||||
file_descriptions: Vec<GameFileDescription>,
|
||||
install_after_download: bool,
|
||||
account_name: Option<String>,
|
||||
language: Option<String>,
|
||||
) {
|
||||
log::info!("Got PeerCommand::DownloadGameFiles");
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
@@ -278,7 +276,7 @@ pub async fn handle_download_game_files_command(
|
||||
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
|
||||
}
|
||||
if install_after_download {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id.clone(), account_name, language);
|
||||
spawn_install_operation(ctx, tx_notify_ui, id.clone());
|
||||
}
|
||||
} else {
|
||||
log::error!("No trusted peers available after majority validation for game {id}");
|
||||
@@ -364,8 +362,6 @@ pub async fn handle_download_game_files_command(
|
||||
&tx_notify_ui_clone,
|
||||
download_id,
|
||||
prepared,
|
||||
account_name,
|
||||
language,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
@@ -423,10 +419,8 @@ pub async fn handle_install_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
account_name: Option<String>,
|
||||
language: Option<String>,
|
||||
) {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id, account_name, language);
|
||||
spawn_install_operation(ctx, tx_notify_ui, id);
|
||||
}
|
||||
|
||||
/// Handles the `UninstallGame` command.
|
||||
@@ -469,27 +463,15 @@ pub async fn handle_cancel_download_command(
|
||||
cancel_token.cancel();
|
||||
}
|
||||
|
||||
fn spawn_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
account_name: Option<String>,
|
||||
language: Option<String>,
|
||||
) {
|
||||
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
let ctx = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
ctx.task_tracker.clone().spawn(async move {
|
||||
run_install_operation(&ctx, &tx_notify_ui, id, account_name, language).await;
|
||||
run_install_operation(&ctx, &tx_notify_ui, id).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
account_name: Option<String>,
|
||||
language: Option<String>,
|
||||
) {
|
||||
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
||||
return;
|
||||
};
|
||||
@@ -499,7 +481,7 @@ async fn run_install_operation(
|
||||
return;
|
||||
}
|
||||
|
||||
run_started_install_operation(ctx, tx_notify_ui, id, prepared, account_name, language).await;
|
||||
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
|
||||
}
|
||||
|
||||
struct PreparedInstallOperation {
|
||||
@@ -551,8 +533,6 @@ async fn run_started_install_operation(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
prepared: PreparedInstallOperation,
|
||||
account_name: Option<String>,
|
||||
language: Option<String>,
|
||||
) {
|
||||
let PreparedInstallOperation {
|
||||
game_root,
|
||||
@@ -577,26 +557,10 @@ async fn run_started_install_operation(
|
||||
let state_dir = ctx.state_dir.as_ref();
|
||||
match operation {
|
||||
InstallOperation::Installing => {
|
||||
install::install(
|
||||
&game_root,
|
||||
state_dir,
|
||||
&id,
|
||||
ctx.unpacker.clone(),
|
||||
account_name.as_deref(),
|
||||
language.as_deref(),
|
||||
)
|
||||
.await
|
||||
install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||
}
|
||||
InstallOperation::Updating => {
|
||||
install::update(
|
||||
&game_root,
|
||||
state_dir,
|
||||
&id,
|
||||
ctx.unpacker.clone(),
|
||||
account_name.as_deref(),
|
||||
language.as_deref(),
|
||||
)
|
||||
.await
|
||||
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1511,7 +1475,7 @@ mod tests {
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -1542,7 +1506,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -1596,8 +1560,7 @@ mod tests {
|
||||
.await
|
||||
);
|
||||
clear_active_download(&ctx, "game").await;
|
||||
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared, None, None)
|
||||
.await;
|
||||
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1664,7 +1627,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
@@ -1696,7 +1659,7 @@ mod tests {
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Installing),
|
||||
@@ -1719,7 +1682,7 @@ mod tests {
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"new archive");
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Updating),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
ffi::OsStr,
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
@@ -12,7 +11,7 @@ use super::{
|
||||
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
|
||||
unpack::Unpacker,
|
||||
};
|
||||
use crate::local_games::version_ini_is_regular_file;
|
||||
use crate::{local_games::version_ini_is_regular_file, state_paths::launch_settings_applied_path};
|
||||
|
||||
const LOCAL_DIR: &str = "local";
|
||||
const INSTALLING_DIR: &str = ".local.installing";
|
||||
@@ -20,8 +19,6 @@ const BACKUP_DIR: &str = ".local.backup";
|
||||
const OWNED_MARKER: &str = ".lanspread_owned";
|
||||
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
||||
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
||||
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
|
||||
const LANGUAGE_FILE: &str = "language.txt";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum FsEntryState {
|
||||
@@ -41,8 +38,6 @@ pub async fn install(
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
account_name: Option<&str>,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
@@ -52,9 +47,10 @@ pub async fn install(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = install_inner(game_root, id, unpacker, account_name, language).await;
|
||||
let result = install_inner(game_root, id, unpacker).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -76,8 +72,6 @@ pub async fn update(
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
account_name: Option<&str>,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
@@ -87,9 +81,10 @@ pub async fn update(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = update_inner(game_root, id, unpacker, account_name, language).await;
|
||||
let result = update_inner(game_root, id, unpacker).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
|
||||
log::warn!(
|
||||
@@ -195,8 +190,6 @@ async fn install_inner(
|
||||
game_root: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
account_name: Option<&str>,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
let local = local_dir(game_root);
|
||||
if path_is_dir(&local).await {
|
||||
@@ -206,21 +199,13 @@ async fn install_inner(
|
||||
let staging = installing_dir(game_root);
|
||||
prepare_owned_empty_dir(&staging).await?;
|
||||
unpack_archives(game_root, &staging, unpacker).await?;
|
||||
write_install_setting_if_present(&staging, ACCOUNT_NAME_FILE, account_name).await?;
|
||||
write_install_setting_if_present(&staging, LANGUAGE_FILE, language).await?;
|
||||
tokio::fs::rename(&staging, &local)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_inner(
|
||||
game_root: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
account_name: Option<&str>,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
||||
let local = local_dir(game_root);
|
||||
let backup = backup_dir(game_root);
|
||||
let staging = installing_dir(game_root);
|
||||
@@ -236,8 +221,6 @@ async fn update_inner(
|
||||
|
||||
prepare_owned_empty_dir(&staging).await?;
|
||||
unpack_archives(game_root, &staging, unpacker).await?;
|
||||
write_install_setting_if_present(&staging, ACCOUNT_NAME_FILE, account_name).await?;
|
||||
write_install_setting_if_present(&staging, LANGUAGE_FILE, language).await?;
|
||||
tokio::fs::rename(&staging, &local)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
|
||||
@@ -291,47 +274,16 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
||||
Ok(archives)
|
||||
}
|
||||
|
||||
async fn write_install_setting_if_present(
|
||||
install_root: &Path,
|
||||
file_name: &str,
|
||||
value: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(path) = find_install_setting_file(install_root, file_name).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
tokio::fs::write(&path, value)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_install_setting_file(root: &Path, file_name: &str) -> eyre::Result<Option<PathBuf>> {
|
||||
let mut pending_dirs = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending_dirs.pop() {
|
||||
let mut entries = tokio::fs::read_dir(&dir).await?;
|
||||
let mut child_dirs = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
let path = entry.path();
|
||||
if entry.file_name() == OsStr::new(file_name) && file_type.is_file() {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
child_dirs.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
child_dirs.sort();
|
||||
child_dirs.reverse();
|
||||
pending_dirs.extend(child_dirs);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
/// Drop the per-game launch-settings marker before committing install/update
|
||||
/// success, so recovery can retry the reset before publishing a clean intent.
|
||||
async fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Result<()> {
|
||||
let marker = launch_settings_applied_path(state_dir, id);
|
||||
remove_file_if_exists(&marker).await.wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to reset launch-settings marker {}",
|
||||
marker.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
||||
@@ -347,6 +299,7 @@ async fn recover_installing(
|
||||
intent: InstallIntent,
|
||||
fs: InstallFsState,
|
||||
) -> eyre::Result<()> {
|
||||
let commit_landed = fs.local == FsEntryState::Present;
|
||||
if let InstallFsState {
|
||||
installing: FsEntryState::Present,
|
||||
..
|
||||
@@ -354,6 +307,9 @@ async fn recover_installing(
|
||||
{
|
||||
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
||||
}
|
||||
if commit_landed {
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
}
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||
}
|
||||
|
||||
@@ -364,6 +320,16 @@ async fn recover_updating(
|
||||
intent: InstallIntent,
|
||||
fs: InstallFsState,
|
||||
) -> eyre::Result<()> {
|
||||
if matches!(
|
||||
fs,
|
||||
InstallFsState {
|
||||
local: FsEntryState::Present,
|
||||
backup: FsEntryState::Present,
|
||||
..
|
||||
}
|
||||
) {
|
||||
reset_launch_settings_marker(state_dir, id).await?;
|
||||
}
|
||||
match fs {
|
||||
InstallFsState {
|
||||
local: FsEntryState::Missing,
|
||||
@@ -638,16 +604,9 @@ mod tests {
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
|
||||
install(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
successful_unpacker(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
install(&root, state.path(), "game", successful_unpacker())
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
assert!(root.join("local").join("payload.txt").is_file());
|
||||
assert!(!root.join(".local.installing").exists());
|
||||
@@ -656,26 +615,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_account_name_missing_file_is_noop() {
|
||||
async fn install_resets_launch_settings_marker() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
|
||||
|
||||
install(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
successful_unpacker(),
|
||||
Some("Alice"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("install should succeed without account file");
|
||||
install(&root, state.path(), "game", successful_unpacker())
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
assert!(root.join("local").join("payload.txt").is_file());
|
||||
assert!(!root.join("local").join(ACCOUNT_NAME_FILE).exists());
|
||||
assert!(!launch_settings_applied_path(state.path(), "game").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -688,7 +640,7 @@ mod tests {
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
let unpacker = Arc::new(FakeUnpacker::default());
|
||||
|
||||
install(&root, state.path(), "game", unpacker.clone(), None, None)
|
||||
install(&root, state.path(), "game", unpacker.clone())
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
@@ -702,63 +654,6 @@ mod tests {
|
||||
assert_eq!(archives, vec!["a.eti", "b.eti"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_overwrites_first_install_setting_files() {
|
||||
struct InstallSettingsUnpacker;
|
||||
|
||||
impl Unpacker for InstallSettingsUnpacker {
|
||||
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||
Box::pin(async move {
|
||||
tokio::fs::create_dir_all(dest.join("a")).await?;
|
||||
tokio::fs::create_dir_all(dest.join("z")).await?;
|
||||
tokio::fs::write(dest.join("a").join(ACCOUNT_NAME_FILE), b"old-a").await?;
|
||||
tokio::fs::write(dest.join("z").join(ACCOUNT_NAME_FILE), b"old-z").await?;
|
||||
tokio::fs::write(dest.join("a").join(LANGUAGE_FILE), b"old-a").await?;
|
||||
tokio::fs::write(dest.join("z").join(LANGUAGE_FILE), b"old-z").await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
|
||||
install(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
Arc::new(InstallSettingsUnpacker),
|
||||
Some("Alice"),
|
||||
Some("german"),
|
||||
)
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("local").join("a").join(ACCOUNT_NAME_FILE))
|
||||
.expect("first account file should be readable"),
|
||||
"Alice"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("local").join("z").join(ACCOUNT_NAME_FILE))
|
||||
.expect("second account file should be readable"),
|
||||
"old-z"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("local").join("a").join(LANGUAGE_FILE))
|
||||
.expect("first language file should be readable"),
|
||||
"german"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("local").join("z").join(LANGUAGE_FILE))
|
||||
.expect("second language file should be readable"),
|
||||
"old-z"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_failure_restores_previous_local() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
@@ -773,8 +668,6 @@ mod tests {
|
||||
state.path(),
|
||||
"game",
|
||||
Arc::new(FakeUnpacker::failing()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("update should fail");
|
||||
@@ -801,8 +694,6 @@ mod tests {
|
||||
state.path(),
|
||||
"game",
|
||||
Arc::new(FakeUnpacker::commit_conflict()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("update should fail at commit rename");
|
||||
@@ -831,22 +722,17 @@ mod tests {
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("local").join("old.txt"), b"old");
|
||||
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
|
||||
|
||||
update(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
successful_unpacker(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("update should succeed");
|
||||
update(&root, state.path(), "game", successful_unpacker())
|
||||
.await
|
||||
.expect("update should succeed");
|
||||
|
||||
assert!(root.join("local").join("payload.txt").is_file());
|
||||
assert!(!root.join("local").join("old.txt").exists());
|
||||
assert!(!root.join(".local.installing").exists());
|
||||
assert!(!root.join(".local.backup").exists());
|
||||
assert!(!launch_settings_applied_path(state.path(), "game").exists());
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
@@ -1088,6 +974,84 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_resets_marker_for_commit_landed_install_or_update() {
|
||||
let state = test_state();
|
||||
let cases = [
|
||||
(
|
||||
"installing-committed",
|
||||
InstallIntentState::Installing,
|
||||
false,
|
||||
),
|
||||
("updating-committed", InstallIntentState::Updating, true),
|
||||
];
|
||||
|
||||
for (id, intent_state, has_backup) in cases {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
|
||||
if has_backup {
|
||||
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
|
||||
}
|
||||
write_file(&launch_settings_applied_path(state.path(), id), b"");
|
||||
write_intent(
|
||||
state.path(),
|
||||
id,
|
||||
&InstallIntent::new(id, intent_state, Some("20250101".into())),
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
|
||||
recover_game_root(&root, state.path(), id)
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
assert!(
|
||||
!launch_settings_applied_path(state.path(), id).exists(),
|
||||
"{id} marker should be reset"
|
||||
);
|
||||
let intent = read_intent(state.path(), id).await;
|
||||
assert_eq!(intent.state, InstallIntentState::None, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_keeps_marker_when_update_rolls_back() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(
|
||||
&root.join(INSTALLING_DIR).join("payload.txt"),
|
||||
INSTALLING_PAYLOAD,
|
||||
);
|
||||
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
|
||||
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
|
||||
write_intent(
|
||||
state.path(),
|
||||
"game",
|
||||
&InstallIntent::new(
|
||||
"game",
|
||||
InstallIntentState::Updating,
|
||||
Some("20250101".into()),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
|
||||
recover_game_root(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
assert!(launch_settings_applied_path(state.path(), "game").exists());
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(LOCAL_DIR).join("payload.txt"))
|
||||
.expect("backup payload should be restored"),
|
||||
BACKUP_PAYLOAD
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
//! One-shot launcher-setting application performed the first time a game is played.
|
||||
//!
|
||||
//! Some games ship per-user setting files somewhere under their installed
|
||||
//! `local/` tree — an `account_name.txt`, a `language.txt`, and/or a
|
||||
//! `SmartSteamEmu.ini` carrying a `PersonaName = ...` line. The first time the
|
||||
//! user launches a game we stamp the launcher's configured username and language
|
||||
//! into whichever of those files exist, then record a per-game marker so the
|
||||
//! step never runs again.
|
||||
//!
|
||||
//! The marker only records that we *tried*: it is written unconditionally after
|
||||
//! the first attempt, whether or not any file or matching line was found. Moving
|
||||
//! this out of the install transaction means already-installed games are fixed
|
||||
//! up on their next play rather than only on a fresh (re)install.
|
||||
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
|
||||
use crate::state_paths::launch_settings_applied_path;
|
||||
|
||||
const LOCAL_DIR: &str = "local";
|
||||
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
|
||||
const LANGUAGE_FILE: &str = "language.txt";
|
||||
const SMART_STEAM_EMU_INI: &str = "SmartSteamEmu.ini";
|
||||
const PERSONA_NAME_KEY: &str = "PersonaName";
|
||||
|
||||
/// What the one-shot launcher-setting step did for a game.
|
||||
///
|
||||
/// These flags are an independent, observable report of each file's fate rather
|
||||
/// than a state machine, so a plain record of bools is the clearest shape here.
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct LaunchSettingsOutcome {
|
||||
/// The marker already existed, so nothing was searched or changed.
|
||||
pub already_applied: bool,
|
||||
/// An `account_name.txt` was found and overwritten with the username.
|
||||
pub account_name_written: bool,
|
||||
/// A `language.txt` was found and overwritten with the language.
|
||||
pub language_written: bool,
|
||||
/// A `SmartSteamEmu.ini` `PersonaName` line was found and rewritten.
|
||||
pub persona_name_written: bool,
|
||||
}
|
||||
|
||||
/// Apply launcher settings once for `game_id`, then mark the attempt as done.
|
||||
///
|
||||
/// If the per-game marker already exists this is a no-op returning
|
||||
/// `already_applied = true`. Otherwise it searches `<game_root>/local/` for the
|
||||
/// known setting files, stamps `account_name` into the first `account_name.txt`
|
||||
/// and first `SmartSteamEmu.ini` with a `PersonaName` line (preserving that
|
||||
/// line's existing line ending) and `language` into the first `language.txt`,
|
||||
/// and — whatever was or was not found — records the marker so the step never
|
||||
/// runs again for this game.
|
||||
pub async fn apply_launch_settings_once(
|
||||
state_dir: &Path,
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
account_name: Option<&str>,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<LaunchSettingsOutcome> {
|
||||
let marker = launch_settings_applied_path(state_dir, game_id);
|
||||
if tokio::fs::try_exists(&marker).await.unwrap_or(false) {
|
||||
return Ok(LaunchSettingsOutcome {
|
||||
already_applied: true,
|
||||
..LaunchSettingsOutcome::default()
|
||||
});
|
||||
}
|
||||
|
||||
let local_root = game_root.join(LOCAL_DIR);
|
||||
let outcome = LaunchSettingsOutcome {
|
||||
already_applied: false,
|
||||
account_name_written: overwrite_first_file(&local_root, ACCOUNT_NAME_FILE, account_name)
|
||||
.await?,
|
||||
language_written: overwrite_first_file(&local_root, LANGUAGE_FILE, language).await?,
|
||||
persona_name_written: rewrite_first_persona_name(&local_root, account_name).await?,
|
||||
};
|
||||
|
||||
mark_applied(&marker).await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Overwrite the first file named `file_name` under `root` with `value`.
|
||||
///
|
||||
/// Returns `false` without touching anything when `value` is `None` or no such
|
||||
/// file exists.
|
||||
async fn overwrite_first_file(
|
||||
root: &Path,
|
||||
file_name: &str,
|
||||
value: Option<&str>,
|
||||
) -> eyre::Result<bool> {
|
||||
let Some(value) = value else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(path) = find_first_file(root, file_name).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
tokio::fs::write(&path, value)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Rewrite the first `PersonaName` line found in any `SmartSteamEmu.ini` under `root`.
|
||||
async fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result<bool> {
|
||||
let Some(persona_name) = persona_name else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
for path in find_files(root, SMART_STEAM_EMU_INI).await? {
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
|
||||
let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
tokio::fs::write(&path, rewritten)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Find the first regular file named `file_name` anywhere under `root`.
|
||||
///
|
||||
/// A missing `root` (for example an uninstalled game with no `local/`) yields
|
||||
/// `None`. Directories are visited in sorted order for deterministic results.
|
||||
async fn find_first_file(root: &Path, file_name: &str) -> eyre::Result<Option<PathBuf>> {
|
||||
let mut pending_dirs = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending_dirs.pop() {
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => continue,
|
||||
Err(err) => {
|
||||
return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display()));
|
||||
}
|
||||
};
|
||||
|
||||
let mut child_dirs = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
let path = entry.path();
|
||||
if file_type.is_dir() {
|
||||
child_dirs.push(path);
|
||||
} else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
}
|
||||
|
||||
child_dirs.sort();
|
||||
child_dirs.reverse();
|
||||
pending_dirs.extend(child_dirs);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Find every regular file named `file_name` anywhere under `root`.
|
||||
///
|
||||
/// A missing `root` yields an empty list. Directories are visited in sorted
|
||||
/// order for deterministic results.
|
||||
async fn find_files(root: &Path, file_name: &str) -> eyre::Result<Vec<PathBuf>> {
|
||||
let mut matches = Vec::new();
|
||||
let mut pending_dirs = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending_dirs.pop() {
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => continue,
|
||||
Err(err) => {
|
||||
return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display()));
|
||||
}
|
||||
};
|
||||
|
||||
let mut child_dirs = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
let path = entry.path();
|
||||
if file_type.is_dir() {
|
||||
child_dirs.push(path);
|
||||
} else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) {
|
||||
matches.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
child_dirs.sort();
|
||||
child_dirs.reverse();
|
||||
pending_dirs.extend(child_dirs);
|
||||
}
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Rewrite the first `PersonaName` line in `content`, preserving its line ending.
|
||||
///
|
||||
/// Returns `None` when no matching line exists, so the caller can skip writing.
|
||||
fn rewrite_persona_name_content(content: &str, persona_name: &str) -> Option<String> {
|
||||
let mut output = String::with_capacity(content.len() + persona_name.len());
|
||||
let mut replaced = false;
|
||||
for segment in content.split_inclusive('\n') {
|
||||
if replaced {
|
||||
output.push_str(segment);
|
||||
continue;
|
||||
}
|
||||
|
||||
let (body, ending) = split_trailing_newline(segment);
|
||||
if let Some(new_body) = rewrite_persona_line(body, persona_name) {
|
||||
output.push_str(&new_body);
|
||||
output.push_str(ending);
|
||||
replaced = true;
|
||||
} else {
|
||||
output.push_str(segment);
|
||||
}
|
||||
}
|
||||
|
||||
replaced.then_some(output)
|
||||
}
|
||||
|
||||
/// Split a `split_inclusive('\n')` segment into its body and trailing newline.
|
||||
fn split_trailing_newline(segment: &str) -> (&str, &str) {
|
||||
if let Some(body) = segment.strip_suffix("\r\n") {
|
||||
(body, "\r\n")
|
||||
} else if let Some(body) = segment.strip_suffix('\n') {
|
||||
(body, "\n")
|
||||
} else {
|
||||
(segment, "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite a single line matching `^\s*PersonaName\s*=\s*...` to use `persona_name`.
|
||||
///
|
||||
/// Leading whitespace and the spacing around `=` are preserved; everything after
|
||||
/// the separator's trailing whitespace is replaced with `persona_name`.
|
||||
fn rewrite_persona_line(line: &str, persona_name: &str) -> Option<String> {
|
||||
let leading_len = line.len() - line.trim_start().len();
|
||||
let (leading, rest) = line.split_at(leading_len);
|
||||
|
||||
let rest = rest.strip_prefix(PERSONA_NAME_KEY)?;
|
||||
let mid_len = rest.len() - rest.trim_start().len();
|
||||
let (mid_ws, after_mid) = rest.split_at(mid_len);
|
||||
|
||||
let after_eq = after_mid.strip_prefix('=')?;
|
||||
let post_len = after_eq.len() - after_eq.trim_start().len();
|
||||
let post_ws = &after_eq[..post_len];
|
||||
|
||||
Some(format!(
|
||||
"{leading}{PERSONA_NAME_KEY}{mid_ws}={post_ws}{persona_name}"
|
||||
))
|
||||
}
|
||||
|
||||
async fn mark_applied(marker: &Path) -> eyre::Result<()> {
|
||||
if let Some(parent) = marker.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
tokio::fs::write(marker, [])
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to write {}", marker.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
#[test]
|
||||
fn rewrites_simple_line_and_preserves_unix_ending() {
|
||||
let content = "[User]\nPersonaName = stubname\nLanguage = english\n";
|
||||
let rewritten =
|
||||
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
||||
assert_eq!(
|
||||
rewritten,
|
||||
"[User]\nPersonaName = realuser\nLanguage = english\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_crlf_line_ending() {
|
||||
let content = "[User]\r\nPersonaName = stubname\r\nLanguage = english\r\n";
|
||||
let rewritten =
|
||||
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
||||
assert_eq!(
|
||||
rewritten,
|
||||
"[User]\r\nPersonaName = realuser\r\nLanguage = english\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_leading_whitespace_and_separator_spacing() {
|
||||
let content = "\tPersonaName=stubname\n";
|
||||
let rewritten =
|
||||
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
||||
assert_eq!(rewritten, "\tPersonaName=realuser\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_final_line_without_trailing_newline() {
|
||||
let content = "PersonaName = stubname";
|
||||
let rewritten =
|
||||
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
||||
assert_eq!(rewritten, "PersonaName = realuser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_similar_keys() {
|
||||
assert!(rewrite_persona_line("PersonaNameExtra = x", "realuser").is_none());
|
||||
assert!(rewrite_persona_line("MyPersonaName = x", "realuser").is_none());
|
||||
assert!(rewrite_persona_line("PersonaName foo", "realuser").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_persona_line() {
|
||||
let content = "[User]\nLanguage = english\n";
|
||||
assert!(rewrite_persona_name_content(content, "realuser").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_username_to_both_files_then_marks_done() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
let account = root.join(LOCAL_DIR).join("profile").join(ACCOUNT_NAME_FILE);
|
||||
let ini = root
|
||||
.join(LOCAL_DIR)
|
||||
.join("config")
|
||||
.join("emu")
|
||||
.join(SMART_STEAM_EMU_INI);
|
||||
let language = root.join(LOCAL_DIR).join(LANGUAGE_FILE);
|
||||
write_file(&account, b"stubname");
|
||||
write_file(&ini, b"[User]\r\nPersonaName = stubname\r\n");
|
||||
write_file(&language, b"english");
|
||||
|
||||
let outcome = apply_launch_settings_once(
|
||||
state.path(),
|
||||
root,
|
||||
"game",
|
||||
Some("realuser"),
|
||||
Some("german"),
|
||||
)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
LaunchSettingsOutcome {
|
||||
already_applied: false,
|
||||
account_name_written: true,
|
||||
language_written: true,
|
||||
persona_name_written: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&account).expect("account file should be readable"),
|
||||
"realuser"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&ini).expect("ini should be readable"),
|
||||
"[User]\r\nPersonaName = realuser\r\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&language).expect("language file should be readable"),
|
||||
"german"
|
||||
);
|
||||
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_noop_once_marker_exists() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
let ini = root.join(LOCAL_DIR).join(SMART_STEAM_EMU_INI);
|
||||
write_file(&ini, b"PersonaName = stubname\n");
|
||||
|
||||
let first = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
||||
.await
|
||||
.expect("first apply should succeed");
|
||||
assert!(first.persona_name_written);
|
||||
assert!(!first.already_applied);
|
||||
|
||||
// Externally reset the value; a second apply must not touch it again.
|
||||
write_file(&ini, b"PersonaName = stubname\n");
|
||||
let second = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
||||
.await
|
||||
.expect("second apply should succeed");
|
||||
|
||||
assert!(second.already_applied);
|
||||
assert!(!second.persona_name_written);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&ini).expect("ini should be readable"),
|
||||
"PersonaName = stubname\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marks_done_even_when_nothing_found() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
write_file(
|
||||
&root.join(LOCAL_DIR).join("readme.txt"),
|
||||
b"no settings here",
|
||||
);
|
||||
|
||||
let outcome = apply_launch_settings_once(
|
||||
state.path(),
|
||||
root,
|
||||
"game",
|
||||
Some("realuser"),
|
||||
Some("german"),
|
||||
)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert_eq!(outcome, LaunchSettingsOutcome::default());
|
||||
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marks_done_when_local_missing() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
|
||||
let outcome = apply_launch_settings_once(
|
||||
state.path(),
|
||||
game.path(),
|
||||
"game",
|
||||
Some("realuser"),
|
||||
Some("german"),
|
||||
)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert_eq!(outcome, LaunchSettingsOutcome::default());
|
||||
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overwrites_first_account_file_in_sorted_order() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
let first = root.join(LOCAL_DIR).join("a").join(ACCOUNT_NAME_FILE);
|
||||
let second = root.join(LOCAL_DIR).join("z").join(ACCOUNT_NAME_FILE);
|
||||
write_file(&first, b"old-a");
|
||||
write_file(&second, b"old-z");
|
||||
|
||||
apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&first).expect("first account file should be readable"),
|
||||
"realuser"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&second).expect("second account file should be readable"),
|
||||
"old-z"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn searches_past_ini_files_without_persona_name() {
|
||||
let state = TempDir::new("lanspread-launch-state");
|
||||
let game = TempDir::new("lanspread-launch-game");
|
||||
let root = game.path();
|
||||
let first = root.join(LOCAL_DIR).join("a").join(SMART_STEAM_EMU_INI);
|
||||
let second = root.join(LOCAL_DIR).join("b").join(SMART_STEAM_EMU_INI);
|
||||
write_file(&first, b"[User]\nLanguage = english\n");
|
||||
write_file(&second, b"PersonaName = stubname\n");
|
||||
|
||||
let outcome =
|
||||
apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
||||
.await
|
||||
.expect("apply should succeed");
|
||||
|
||||
assert!(outcome.persona_name_written);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&first).expect("first ini should be readable"),
|
||||
"[User]\nLanguage = english\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&second).expect("second ini should be readable"),
|
||||
"PersonaName = realuser\n"
|
||||
);
|
||||
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("parent dir should be created");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod events;
|
||||
mod handlers;
|
||||
mod identity;
|
||||
mod install;
|
||||
mod launch_settings;
|
||||
mod library;
|
||||
mod local_games;
|
||||
mod migration;
|
||||
@@ -77,7 +78,11 @@ use crate::{
|
||||
},
|
||||
state_paths::resolve_state_dir,
|
||||
};
|
||||
pub use crate::{startup::PeerRuntimeHandle, state_paths::setup_done_path};
|
||||
pub use crate::{
|
||||
launch_settings::{LaunchSettingsOutcome, apply_launch_settings_once},
|
||||
startup::PeerRuntimeHandle,
|
||||
state_paths::{launch_settings_applied_path, setup_done_path},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Public API types
|
||||
@@ -229,23 +234,15 @@ pub enum PeerCommand {
|
||||
DownloadGameFiles {
|
||||
id: String,
|
||||
file_descriptions: Vec<GameFileDescription>,
|
||||
account_name: Option<String>,
|
||||
language: Option<String>,
|
||||
},
|
||||
/// Download game files with an explicit install policy.
|
||||
DownloadGameFilesWithOptions {
|
||||
id: String,
|
||||
file_descriptions: Vec<GameFileDescription>,
|
||||
install_after_download: bool,
|
||||
account_name: Option<String>,
|
||||
language: Option<String>,
|
||||
},
|
||||
/// Install already-downloaded archives into `local/`.
|
||||
InstallGame {
|
||||
id: String,
|
||||
account_name: Option<String>,
|
||||
language: Option<String>,
|
||||
},
|
||||
InstallGame { id: String },
|
||||
/// Remove only the `local/` install for a game.
|
||||
UninstallGame { id: String },
|
||||
/// Remove downloaded archive files for an uninstalled game.
|
||||
@@ -413,26 +410,14 @@ async fn handle_peer_commands(
|
||||
PeerCommand::DownloadGameFiles {
|
||||
id,
|
||||
file_descriptions,
|
||||
account_name,
|
||||
language,
|
||||
} => {
|
||||
handle_download_game_files_command(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
id,
|
||||
file_descriptions,
|
||||
true,
|
||||
account_name,
|
||||
language,
|
||||
)
|
||||
.await;
|
||||
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true)
|
||||
.await;
|
||||
}
|
||||
PeerCommand::DownloadGameFilesWithOptions {
|
||||
id,
|
||||
file_descriptions,
|
||||
install_after_download,
|
||||
account_name,
|
||||
language,
|
||||
} => {
|
||||
handle_download_game_files_command(
|
||||
ctx,
|
||||
@@ -440,17 +425,11 @@ async fn handle_peer_commands(
|
||||
id,
|
||||
file_descriptions,
|
||||
install_after_download,
|
||||
account_name,
|
||||
language,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
PeerCommand::InstallGame {
|
||||
id,
|
||||
account_name,
|
||||
language,
|
||||
} => {
|
||||
handle_install_game_command(ctx, tx_notify_ui, id, account_name, language).await;
|
||||
PeerCommand::InstallGame { id } => {
|
||||
handle_install_game_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::UninstallGame { id } => {
|
||||
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
||||
|
||||
@@ -5,6 +5,7 @@ const LOCAL_LIBRARY_DIR: &str = "local_library";
|
||||
const LOCAL_LIBRARY_INDEX_FILE: &str = "index.json";
|
||||
const GAMES_DIR: &str = "games";
|
||||
const SETUP_DONE_FILE: &str = "setup_done";
|
||||
const LAUNCH_SETTINGS_APPLIED_FILE: &str = "launch_settings_applied";
|
||||
|
||||
pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf {
|
||||
if let Some(dir) = explicit {
|
||||
@@ -40,3 +41,8 @@ pub(crate) fn game_state_dir(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||
pub fn setup_done_path(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||
game_state_dir(state_dir, game_id).join(SETUP_DONE_FILE)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn launch_settings_applied_path(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||
game_state_dir(state_dir, game_id).join(LAUNCH_SETTINGS_APPLIED_FILE)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ struct LanSpreadState {
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
||||
pending_install_settings: Arc<RwLock<HashMap<String, InstallSettings>>>,
|
||||
state_dir: OnceLock<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -180,22 +179,12 @@ async fn install_game(
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let install_settings = install_settings(&language, &username);
|
||||
let _ = (language, username);
|
||||
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
||||
let command = if !downloaded {
|
||||
state
|
||||
.inner()
|
||||
.pending_install_settings
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), install_settings);
|
||||
PeerCommand::GetGame(id.clone())
|
||||
} else if !installed {
|
||||
PeerCommand::InstallGame {
|
||||
id: id.clone(),
|
||||
account_name: Some(install_settings.account_name),
|
||||
language: Some(install_settings.language),
|
||||
}
|
||||
PeerCommand::InstallGame { id: id.clone() }
|
||||
} else {
|
||||
log::info!("Game is already installed: {id}");
|
||||
return Ok(false);
|
||||
@@ -203,12 +192,6 @@ async fn install_game(
|
||||
|
||||
if let Err(e) = peer_ctrl.send(command) {
|
||||
log::error!("Failed to send message to peer: {e:?}");
|
||||
state
|
||||
.inner()
|
||||
.pending_install_settings
|
||||
.write()
|
||||
.await
|
||||
.remove(&id);
|
||||
return Ok(false);
|
||||
}
|
||||
true
|
||||
@@ -242,21 +225,10 @@ async fn update_game(
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
||||
|
||||
let _ = (language, username);
|
||||
if let Some(peer_ctrl) = peer_ctrl {
|
||||
state
|
||||
.inner()
|
||||
.pending_install_settings
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), install_settings(&language, &username));
|
||||
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
|
||||
log::error!("Failed to send message to peer: {e:?}");
|
||||
state
|
||||
.inner()
|
||||
.pending_install_settings
|
||||
.write()
|
||||
.await
|
||||
.remove(&id);
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
@@ -349,13 +321,6 @@ async fn cancel_download(
|
||||
id: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
state
|
||||
.inner()
|
||||
.pending_install_settings
|
||||
.write()
|
||||
.await
|
||||
.remove(&id);
|
||||
|
||||
let is_active_download = {
|
||||
let active_operations = state.inner().active_operations.read().await;
|
||||
matches!(
|
||||
@@ -672,6 +637,8 @@ async fn run_game_windows(
|
||||
}
|
||||
}
|
||||
|
||||
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
|
||||
|
||||
if game_start_bin.exists() {
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
@@ -688,6 +655,32 @@ async fn run_game_windows(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stamp the launcher's username and language into the installed game's setting
|
||||
/// files the first time it is played. Uses the same processed values the install
|
||||
/// transaction used to write before this step moved to play time.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
async fn apply_launch_settings(
|
||||
state_dir: &Path,
|
||||
game_path: &Path,
|
||||
id: &str,
|
||||
language: &str,
|
||||
username: &str,
|
||||
) {
|
||||
let settings = install_settings(language, username);
|
||||
match lanspread_peer::apply_launch_settings_once(
|
||||
state_dir,
|
||||
game_path,
|
||||
id,
|
||||
Some(&settings.account_name),
|
||||
Some(&settings.language),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => log::info!("launch settings for {id}: {outcome:?}"),
|
||||
Err(e) => log::error!("failed to apply launch settings for {id}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn run_game(
|
||||
id: String,
|
||||
@@ -747,6 +740,12 @@ async fn start_server_windows(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
|
||||
log::error!("app state directory is not initialized; cannot start server");
|
||||
return Ok(false);
|
||||
};
|
||||
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
|
||||
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&server_script_params(&server_start_bin, &id, &settings),
|
||||
@@ -1504,7 +1503,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
PeerEvent::NoPeersHaveGame { id } => {
|
||||
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
|
||||
clear_pending_install_settings(app_handle, &id).await;
|
||||
emit_game_id_event(
|
||||
app_handle,
|
||||
"game-no-peers",
|
||||
@@ -1543,7 +1541,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
PeerEvent::DownloadGameFilesFailed { id } => {
|
||||
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
||||
clear_pending_install_settings(app_handle, &id).await;
|
||||
emit_game_id_event(
|
||||
app_handle,
|
||||
"game-download-failed",
|
||||
@@ -1553,7 +1550,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
||||
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
|
||||
clear_pending_install_settings(app_handle, &id).await;
|
||||
emit_game_id_event(
|
||||
app_handle,
|
||||
"game-download-peers-gone",
|
||||
@@ -1678,11 +1674,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_pending_install_settings(app_handle: &AppHandle, id: &str) {
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
state.pending_install_settings.write().await.remove(id);
|
||||
}
|
||||
|
||||
async fn handle_got_game_files(
|
||||
app_handle: &AppHandle,
|
||||
id: String,
|
||||
@@ -1697,17 +1688,11 @@ async fn handle_got_game_files(
|
||||
);
|
||||
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
let install_settings = state.pending_install_settings.write().await.remove(&id);
|
||||
let (account_name, language) = install_settings.map_or((None, None), |settings| {
|
||||
(Some(settings.account_name), Some(settings.language))
|
||||
});
|
||||
let peer_ctrl = state.peer_ctrl.read().await.clone();
|
||||
if let Some(peer_ctrl) = peer_ctrl
|
||||
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
|
||||
id,
|
||||
file_descriptions,
|
||||
account_name,
|
||||
language,
|
||||
})
|
||||
{
|
||||
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
||||
|
||||
Reference in New Issue
Block a user