Compare commits

...

2 Commits

Author SHA1 Message Date
ddidderr 18f21bdf30 fix(launch): stamp first-use settings on every launch path
First-use launch settings moved out of install/update transactions, but three
edge cases could still leave archive stub values in place while the marker said
the settings pass had already happened.

Start Server now runs the same stamping preflight as Play before launching
server_start.cmd. That covers games whose server scripts read account_name.txt,
language.txt, or SmartSteamEmu.ini before the user ever presses Play.

Install and update now reset the per-game marker before committing
InstallIntent::None. Recovery also clears the marker for install/update states
where the new local tree has already landed, so a crash after promotion cannot
publish a clean intent while preserving a stale marker. Rollback recovery keeps
the marker, because the old local tree remains the active install.

SmartSteamEmu.ini stamping now searches every matching file until one contains a
PersonaName line. This keeps a decoy or incomplete INI from permanently blocking
the real one while still preserving early-exit behavior for account_name.txt and
language.txt, which only need the first matching file.

Test Plan:
- just fmt
- just test
- just clippy
- git diff --check

Refs: local review findings
2026-05-29 06:52:00 +02:00
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
11 changed files with 773 additions and 298 deletions
+37
View File
@@ -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
@@ -0,0 +1 @@
20250101
+14
View File
@@ -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()
+28 -4
View File
@@ -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(());
+14 -51
View File
@@ -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),
+123 -159
View File
@@ -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");
}
}
+11 -32
View File
@@ -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;
+6
View File
@@ -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}");