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>
This commit is contained in:
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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
|
## 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
|
cover duplicate-source and shared-game cases, but not the three-version skew
|
||||||
until a dedicated fixture or temporary games root is prepared.
|
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
|
## 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
|
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
|
||||||
|
|
||||||
- Code under test included `5c4976d` (`fix(peer): settle local state before
|
- 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 {
|
Uninstall {
|
||||||
game_id: String,
|
game_id: String,
|
||||||
},
|
},
|
||||||
|
Play {
|
||||||
|
game_id: String,
|
||||||
|
username: String,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
WaitPeers {
|
WaitPeers {
|
||||||
count: usize,
|
count: usize,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@@ -60,6 +65,7 @@ impl CliCommand {
|
|||||||
Self::Download { .. } => "download",
|
Self::Download { .. } => "download",
|
||||||
Self::Install { .. } => "install",
|
Self::Install { .. } => "install",
|
||||||
Self::Uninstall { .. } => "uninstall",
|
Self::Uninstall { .. } => "uninstall",
|
||||||
|
Self::Play { .. } => "play",
|
||||||
Self::WaitPeers { .. } => "wait-peers",
|
Self::WaitPeers { .. } => "wait-peers",
|
||||||
Self::Connect { .. } => "connect",
|
Self::Connect { .. } => "connect",
|
||||||
Self::Shutdown => "shutdown",
|
Self::Shutdown => "shutdown",
|
||||||
@@ -101,6 +107,14 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
|||||||
"uninstall" => CliCommand::Uninstall {
|
"uninstall" => CliCommand::Uninstall {
|
||||||
game_id: game_id(object)?,
|
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 {
|
"wait-peers" => CliCommand::WaitPeers {
|
||||||
count: required_u64(object, "count")?
|
count: required_u64(object, "count")?
|
||||||
.try_into()
|
.try_into()
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ struct SharedState {
|
|||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<HashSet<String>>>,
|
||||||
notify: Notify,
|
notify: Notify,
|
||||||
|
games_dir: PathBuf,
|
||||||
|
state_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -153,6 +155,8 @@ async fn main() -> eyre::Result<()> {
|
|||||||
peer_game_db,
|
peer_game_db,
|
||||||
catalog: catalog.clone(),
|
catalog: catalog.clone(),
|
||||||
notify: Notify::new(),
|
notify: Notify::new(),
|
||||||
|
games_dir: args.games_dir.clone(),
|
||||||
|
state_dir: args.state_dir.clone(),
|
||||||
});
|
});
|
||||||
let writer = JsonlWriter::new();
|
let writer = JsonlWriter::new();
|
||||||
|
|
||||||
@@ -240,8 +244,6 @@ async fn handle_command(
|
|||||||
id: game_id.clone(),
|
id: game_id.clone(),
|
||||||
file_descriptions: files,
|
file_descriptions: files,
|
||||||
install_after_download: *install_after_download,
|
install_after_download: *install_after_download,
|
||||||
account_name: None,
|
|
||||||
language: None,
|
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
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?;
|
ensure_no_active_operation(shared, game_id).await?;
|
||||||
sender.send(PeerCommand::InstallGame {
|
sender.send(PeerCommand::InstallGame {
|
||||||
id: game_id.clone(),
|
id: game_id.clone(),
|
||||||
account_name: None,
|
|
||||||
language: None,
|
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"queued": true, "game_id": game_id}))
|
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 } => {
|
CliCommand::Uninstall { game_id } => {
|
||||||
ensure_catalog_game(shared, game_id).await?;
|
ensure_catalog_game(shared, game_id).await?;
|
||||||
ensure_no_active_operation(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<()> {
|
async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
|
||||||
if shared.catalog.read().await.contains(game_id) {
|
if shared.catalog.read().await.contains(game_id) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -199,8 +199,6 @@ pub async fn handle_download_game_files_command(
|
|||||||
id: String,
|
id: String,
|
||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
install_after_download: bool,
|
install_after_download: bool,
|
||||||
account_name: Option<String>,
|
|
||||||
language: Option<String>,
|
|
||||||
) {
|
) {
|
||||||
log::info!("Got PeerCommand::DownloadGameFiles");
|
log::info!("Got PeerCommand::DownloadGameFiles");
|
||||||
if !catalog_contains(ctx, &id).await {
|
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}");
|
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
|
||||||
}
|
}
|
||||||
if install_after_download {
|
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 {
|
} else {
|
||||||
log::error!("No trusted peers available after majority validation for game {id}");
|
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,
|
&tx_notify_ui_clone,
|
||||||
download_id,
|
download_id,
|
||||||
prepared,
|
prepared,
|
||||||
account_name,
|
|
||||||
language,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
@@ -423,10 +419,8 @@ pub async fn handle_install_game_command(
|
|||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
id: String,
|
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.
|
/// Handles the `UninstallGame` command.
|
||||||
@@ -469,27 +463,15 @@ pub async fn handle_cancel_download_command(
|
|||||||
cancel_token.cancel();
|
cancel_token.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_install_operation(
|
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||||
ctx: &Ctx,
|
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
||||||
id: String,
|
|
||||||
account_name: Option<String>,
|
|
||||||
language: Option<String>,
|
|
||||||
) {
|
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
let tx_notify_ui = tx_notify_ui.clone();
|
let tx_notify_ui = tx_notify_ui.clone();
|
||||||
ctx.task_tracker.clone().spawn(async move {
|
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(
|
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||||
ctx: &Ctx,
|
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
||||||
id: String,
|
|
||||||
account_name: Option<String>,
|
|
||||||
language: Option<String>,
|
|
||||||
) {
|
|
||||||
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -499,7 +481,7 @@ async fn run_install_operation(
|
|||||||
return;
|
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 {
|
struct PreparedInstallOperation {
|
||||||
@@ -551,8 +533,6 @@ async fn run_started_install_operation(
|
|||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
id: String,
|
id: String,
|
||||||
prepared: PreparedInstallOperation,
|
prepared: PreparedInstallOperation,
|
||||||
account_name: Option<String>,
|
|
||||||
language: Option<String>,
|
|
||||||
) {
|
) {
|
||||||
let PreparedInstallOperation {
|
let PreparedInstallOperation {
|
||||||
game_root,
|
game_root,
|
||||||
@@ -577,26 +557,10 @@ async fn run_started_install_operation(
|
|||||||
let state_dir = ctx.state_dir.as_ref();
|
let state_dir = ctx.state_dir.as_ref();
|
||||||
match operation {
|
match operation {
|
||||||
InstallOperation::Installing => {
|
InstallOperation::Installing => {
|
||||||
install::install(
|
install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||||
&game_root,
|
|
||||||
state_dir,
|
|
||||||
&id,
|
|
||||||
ctx.unpacker.clone(),
|
|
||||||
account_name.as_deref(),
|
|
||||||
language.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
InstallOperation::Updating => {
|
InstallOperation::Updating => {
|
||||||
install::update(
|
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||||
&game_root,
|
|
||||||
state_dir,
|
|
||||||
&id,
|
|
||||||
ctx.unpacker.clone(),
|
|
||||||
account_name.as_deref(),
|
|
||||||
language.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1511,7 +1475,7 @@ mod tests {
|
|||||||
update_and_announce_games(&ctx, &tx, scan).await;
|
update_and_announce_games(&ctx, &tx, scan).await;
|
||||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1542,7 +1506,7 @@ mod tests {
|
|||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1596,8 +1560,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
);
|
);
|
||||||
clear_active_download(&ctx, "game").await;
|
clear_active_download(&ctx, "game").await;
|
||||||
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared, None, None)
|
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await;
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1664,7 +1627,7 @@ mod tests {
|
|||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1696,7 +1659,7 @@ mod tests {
|
|||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
active_update("game", ActiveOperationKind::Installing),
|
active_update("game", ActiveOperationKind::Installing),
|
||||||
@@ -1719,7 +1682,7 @@ mod tests {
|
|||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
write_file(&root.join("game.eti"), b"new archive");
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
active_update("game", ActiveOperationKind::Updating),
|
active_update("game", ActiveOperationKind::Updating),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
collections::HashSet,
|
||||||
ffi::OsStr,
|
|
||||||
io::ErrorKind,
|
io::ErrorKind,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
@@ -12,7 +11,7 @@ use super::{
|
|||||||
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
|
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
|
||||||
unpack::Unpacker,
|
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 LOCAL_DIR: &str = "local";
|
||||||
const INSTALLING_DIR: &str = ".local.installing";
|
const INSTALLING_DIR: &str = ".local.installing";
|
||||||
@@ -20,8 +19,6 @@ const BACKUP_DIR: &str = ".local.backup";
|
|||||||
const OWNED_MARKER: &str = ".lanspread_owned";
|
const OWNED_MARKER: &str = ".lanspread_owned";
|
||||||
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
||||||
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum FsEntryState {
|
enum FsEntryState {
|
||||||
@@ -41,8 +38,6 @@ pub async fn install(
|
|||||||
state_dir: &Path,
|
state_dir: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
account_name: Option<&str>,
|
|
||||||
language: Option<&str>,
|
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let eti_version = read_downloaded_version(game_root).await;
|
let eti_version = read_downloaded_version(game_root).await;
|
||||||
write_intent(
|
write_intent(
|
||||||
@@ -52,10 +47,11 @@ pub async fn install(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let result = install_inner(game_root, id, unpacker, account_name, language).await;
|
let result = install_inner(game_root, id, unpacker).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||||
|
reset_launch_settings_marker(state_dir, id).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -76,8 +72,6 @@ pub async fn update(
|
|||||||
state_dir: &Path,
|
state_dir: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
account_name: Option<&str>,
|
|
||||||
language: Option<&str>,
|
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let eti_version = read_downloaded_version(game_root).await;
|
let eti_version = read_downloaded_version(game_root).await;
|
||||||
write_intent(
|
write_intent(
|
||||||
@@ -87,10 +81,11 @@ pub async fn update(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let result = update_inner(game_root, id, unpacker, account_name, language).await;
|
let result = update_inner(game_root, id, unpacker).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||||
|
reset_launch_settings_marker(state_dir, id).await;
|
||||||
if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
|
if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to clean install backup {}: {err}",
|
"Failed to clean install backup {}: {err}",
|
||||||
@@ -195,8 +190,6 @@ async fn install_inner(
|
|||||||
game_root: &Path,
|
game_root: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
account_name: Option<&str>,
|
|
||||||
language: Option<&str>,
|
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let local = local_dir(game_root);
|
let local = local_dir(game_root);
|
||||||
if path_is_dir(&local).await {
|
if path_is_dir(&local).await {
|
||||||
@@ -206,21 +199,13 @@ async fn install_inner(
|
|||||||
let staging = installing_dir(game_root);
|
let staging = installing_dir(game_root);
|
||||||
prepare_owned_empty_dir(&staging).await?;
|
prepare_owned_empty_dir(&staging).await?;
|
||||||
unpack_archives(game_root, &staging, unpacker).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)
|
tokio::fs::rename(&staging, &local)
|
||||||
.await
|
.await
|
||||||
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
|
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_inner(
|
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
||||||
game_root: &Path,
|
|
||||||
id: &str,
|
|
||||||
unpacker: Arc<dyn Unpacker>,
|
|
||||||
account_name: Option<&str>,
|
|
||||||
language: Option<&str>,
|
|
||||||
) -> eyre::Result<()> {
|
|
||||||
let local = local_dir(game_root);
|
let local = local_dir(game_root);
|
||||||
let backup = backup_dir(game_root);
|
let backup = backup_dir(game_root);
|
||||||
let staging = installing_dir(game_root);
|
let staging = installing_dir(game_root);
|
||||||
@@ -236,8 +221,6 @@ async fn update_inner(
|
|||||||
|
|
||||||
prepare_owned_empty_dir(&staging).await?;
|
prepare_owned_empty_dir(&staging).await?;
|
||||||
unpack_archives(game_root, &staging, unpacker).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)
|
tokio::fs::rename(&staging, &local)
|
||||||
.await
|
.await
|
||||||
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
|
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
|
||||||
@@ -291,47 +274,18 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
|||||||
Ok(archives)
|
Ok(archives)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_install_setting_if_present(
|
/// Drop the per-game launch-settings marker so the launcher reapplies the
|
||||||
install_root: &Path,
|
/// username/language to the freshly (re)created `local/` on the next play. A
|
||||||
file_name: &str,
|
/// failure here is non-fatal; it only means the launcher might skip a settings
|
||||||
value: Option<&str>,
|
/// pass for an install whose marker was already absent.
|
||||||
) -> eyre::Result<()> {
|
async fn reset_launch_settings_marker(state_dir: &Path, id: &str) {
|
||||||
let Some(value) = value else {
|
let marker = launch_settings_applied_path(state_dir, id);
|
||||||
return Ok(());
|
if let Err(err) = remove_file_if_exists(&marker).await {
|
||||||
};
|
log::warn!(
|
||||||
|
"Failed to reset launch-settings marker {}: {err}",
|
||||||
let Some(path) = find_install_setting_file(install_root, file_name).await? else {
|
marker.display()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
||||||
@@ -638,14 +592,7 @@ mod tests {
|
|||||||
write_file(&root.join("game.eti"), b"archive");
|
write_file(&root.join("game.eti"), b"archive");
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
|
|
||||||
install(
|
install(&root, state.path(), "game", successful_unpacker())
|
||||||
&root,
|
|
||||||
state.path(),
|
|
||||||
"game",
|
|
||||||
successful_unpacker(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
|
|
||||||
@@ -656,26 +603,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 temp = TempDir::new("lanspread-install");
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join("game.eti"), b"archive");
|
write_file(&root.join("game.eti"), b"archive");
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
|
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
|
||||||
|
|
||||||
install(
|
install(&root, state.path(), "game", successful_unpacker())
|
||||||
&root,
|
|
||||||
state.path(),
|
|
||||||
"game",
|
|
||||||
successful_unpacker(),
|
|
||||||
Some("Alice"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed without account file");
|
.expect("install should succeed");
|
||||||
|
|
||||||
assert!(root.join("local").join("payload.txt").is_file());
|
assert!(!launch_settings_applied_path(state.path(), "game").exists());
|
||||||
assert!(!root.join("local").join(ACCOUNT_NAME_FILE).exists());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -688,7 +628,7 @@ mod tests {
|
|||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
let unpacker = Arc::new(FakeUnpacker::default());
|
let unpacker = Arc::new(FakeUnpacker::default());
|
||||||
|
|
||||||
install(&root, state.path(), "game", unpacker.clone(), None, None)
|
install(&root, state.path(), "game", unpacker.clone())
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
|
|
||||||
@@ -702,63 +642,6 @@ mod tests {
|
|||||||
assert_eq!(archives, vec!["a.eti", "b.eti"]);
|
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]
|
#[tokio::test]
|
||||||
async fn update_failure_restores_previous_local() {
|
async fn update_failure_restores_previous_local() {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
@@ -773,8 +656,6 @@ mod tests {
|
|||||||
state.path(),
|
state.path(),
|
||||||
"game",
|
"game",
|
||||||
Arc::new(FakeUnpacker::failing()),
|
Arc::new(FakeUnpacker::failing()),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("update should fail");
|
.expect_err("update should fail");
|
||||||
@@ -801,8 +682,6 @@ mod tests {
|
|||||||
state.path(),
|
state.path(),
|
||||||
"game",
|
"game",
|
||||||
Arc::new(FakeUnpacker::commit_conflict()),
|
Arc::new(FakeUnpacker::commit_conflict()),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("update should fail at commit rename");
|
.expect_err("update should fail at commit rename");
|
||||||
@@ -832,14 +711,7 @@ mod tests {
|
|||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
write_file(&root.join("local").join("old.txt"), b"old");
|
write_file(&root.join("local").join("old.txt"), b"old");
|
||||||
|
|
||||||
update(
|
update(&root, state.path(), "game", successful_unpacker())
|
||||||
&root,
|
|
||||||
state.path(),
|
|
||||||
"game",
|
|
||||||
successful_unpacker(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.expect("update should succeed");
|
.expect("update should succeed");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
//! 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 the first `SmartSteamEmu.ini` `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 `PersonaName` line in the first `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);
|
||||||
|
};
|
||||||
|
let Some(path) = find_first_file(root, SMART_STEAM_EMU_INI).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::fs::write(&path, rewritten)
|
||||||
|
.await
|
||||||
|
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handlers;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod install;
|
mod install;
|
||||||
|
mod launch_settings;
|
||||||
mod library;
|
mod library;
|
||||||
mod local_games;
|
mod local_games;
|
||||||
mod migration;
|
mod migration;
|
||||||
@@ -77,7 +78,11 @@ use crate::{
|
|||||||
},
|
},
|
||||||
state_paths::resolve_state_dir,
|
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
|
// Public API types
|
||||||
@@ -229,23 +234,15 @@ pub enum PeerCommand {
|
|||||||
DownloadGameFiles {
|
DownloadGameFiles {
|
||||||
id: String,
|
id: String,
|
||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
account_name: Option<String>,
|
|
||||||
language: Option<String>,
|
|
||||||
},
|
},
|
||||||
/// Download game files with an explicit install policy.
|
/// Download game files with an explicit install policy.
|
||||||
DownloadGameFilesWithOptions {
|
DownloadGameFilesWithOptions {
|
||||||
id: String,
|
id: String,
|
||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
install_after_download: bool,
|
install_after_download: bool,
|
||||||
account_name: Option<String>,
|
|
||||||
language: Option<String>,
|
|
||||||
},
|
},
|
||||||
/// Install already-downloaded archives into `local/`.
|
/// Install already-downloaded archives into `local/`.
|
||||||
InstallGame {
|
InstallGame { id: String },
|
||||||
id: String,
|
|
||||||
account_name: Option<String>,
|
|
||||||
language: Option<String>,
|
|
||||||
},
|
|
||||||
/// Remove only the `local/` install for a game.
|
/// Remove only the `local/` install for a game.
|
||||||
UninstallGame { id: String },
|
UninstallGame { id: String },
|
||||||
/// Remove downloaded archive files for an uninstalled game.
|
/// Remove downloaded archive files for an uninstalled game.
|
||||||
@@ -413,26 +410,14 @@ async fn handle_peer_commands(
|
|||||||
PeerCommand::DownloadGameFiles {
|
PeerCommand::DownloadGameFiles {
|
||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
account_name,
|
|
||||||
language,
|
|
||||||
} => {
|
} => {
|
||||||
handle_download_game_files_command(
|
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true)
|
||||||
ctx,
|
|
||||||
tx_notify_ui,
|
|
||||||
id,
|
|
||||||
file_descriptions,
|
|
||||||
true,
|
|
||||||
account_name,
|
|
||||||
language,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
PeerCommand::DownloadGameFilesWithOptions {
|
PeerCommand::DownloadGameFilesWithOptions {
|
||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
install_after_download,
|
install_after_download,
|
||||||
account_name,
|
|
||||||
language,
|
|
||||||
} => {
|
} => {
|
||||||
handle_download_game_files_command(
|
handle_download_game_files_command(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -440,17 +425,11 @@ async fn handle_peer_commands(
|
|||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
install_after_download,
|
install_after_download,
|
||||||
account_name,
|
|
||||||
language,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
PeerCommand::InstallGame {
|
PeerCommand::InstallGame { id } => {
|
||||||
id,
|
handle_install_game_command(ctx, tx_notify_ui, id).await;
|
||||||
account_name,
|
|
||||||
language,
|
|
||||||
} => {
|
|
||||||
handle_install_game_command(ctx, tx_notify_ui, id, account_name, language).await;
|
|
||||||
}
|
}
|
||||||
PeerCommand::UninstallGame { id } => {
|
PeerCommand::UninstallGame { id } => {
|
||||||
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
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 LOCAL_LIBRARY_INDEX_FILE: &str = "index.json";
|
||||||
const GAMES_DIR: &str = "games";
|
const GAMES_DIR: &str = "games";
|
||||||
const SETUP_DONE_FILE: &str = "setup_done";
|
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 {
|
pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf {
|
||||||
if let Some(dir) = explicit {
|
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 {
|
pub fn setup_done_path(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||||
game_state_dir(state_dir, game_id).join(SETUP_DONE_FILE)
|
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>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<HashSet<String>>>,
|
||||||
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
||||||
pending_install_settings: Arc<RwLock<HashMap<String, InstallSettings>>>,
|
|
||||||
state_dir: OnceLock<PathBuf>,
|
state_dir: OnceLock<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,22 +179,12 @@ async fn install_game(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let install_settings = install_settings(&language, &username);
|
let _ = (language, username);
|
||||||
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
||||||
let command = if !downloaded {
|
let command = if !downloaded {
|
||||||
state
|
|
||||||
.inner()
|
|
||||||
.pending_install_settings
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(id.clone(), install_settings);
|
|
||||||
PeerCommand::GetGame(id.clone())
|
PeerCommand::GetGame(id.clone())
|
||||||
} else if !installed {
|
} else if !installed {
|
||||||
PeerCommand::InstallGame {
|
PeerCommand::InstallGame { id: id.clone() }
|
||||||
id: id.clone(),
|
|
||||||
account_name: Some(install_settings.account_name),
|
|
||||||
language: Some(install_settings.language),
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log::info!("Game is already installed: {id}");
|
log::info!("Game is already installed: {id}");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -203,12 +192,6 @@ async fn install_game(
|
|||||||
|
|
||||||
if let Err(e) = peer_ctrl.send(command) {
|
if let Err(e) = peer_ctrl.send(command) {
|
||||||
log::error!("Failed to send message to peer: {e:?}");
|
log::error!("Failed to send message to peer: {e:?}");
|
||||||
state
|
|
||||||
.inner()
|
|
||||||
.pending_install_settings
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.remove(&id);
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
@@ -242,21 +225,10 @@ async fn update_game(
|
|||||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||||
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
||||||
|
|
||||||
|
let _ = (language, username);
|
||||||
if let Some(peer_ctrl) = peer_ctrl {
|
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() }) {
|
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
|
||||||
log::error!("Failed to send message to peer: {e:?}");
|
log::error!("Failed to send message to peer: {e:?}");
|
||||||
state
|
|
||||||
.inner()
|
|
||||||
.pending_install_settings
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.remove(&id);
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -349,13 +321,6 @@ async fn cancel_download(
|
|||||||
id: String,
|
id: String,
|
||||||
state: tauri::State<'_, LanSpreadState>,
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
) -> tauri::Result<bool> {
|
) -> tauri::Result<bool> {
|
||||||
state
|
|
||||||
.inner()
|
|
||||||
.pending_install_settings
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.remove(&id);
|
|
||||||
|
|
||||||
let is_active_download = {
|
let is_active_download = {
|
||||||
let active_operations = state.inner().active_operations.read().await;
|
let active_operations = state.inner().active_operations.read().await;
|
||||||
matches!(
|
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() {
|
if game_start_bin.exists() {
|
||||||
let result = run_as_admin(
|
let result = run_as_admin(
|
||||||
"cmd.exe",
|
"cmd.exe",
|
||||||
@@ -688,6 +655,32 @@ async fn run_game_windows(
|
|||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
async fn run_game(
|
async fn run_game(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -1504,7 +1497,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
PeerEvent::NoPeersHaveGame { id } => {
|
PeerEvent::NoPeersHaveGame { id } => {
|
||||||
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
|
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
|
||||||
clear_pending_install_settings(app_handle, &id).await;
|
|
||||||
emit_game_id_event(
|
emit_game_id_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"game-no-peers",
|
"game-no-peers",
|
||||||
@@ -1543,7 +1535,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
PeerEvent::DownloadGameFilesFailed { id } => {
|
PeerEvent::DownloadGameFilesFailed { id } => {
|
||||||
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
||||||
clear_pending_install_settings(app_handle, &id).await;
|
|
||||||
emit_game_id_event(
|
emit_game_id_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"game-download-failed",
|
"game-download-failed",
|
||||||
@@ -1553,7 +1544,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
||||||
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
|
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
|
||||||
clear_pending_install_settings(app_handle, &id).await;
|
|
||||||
emit_game_id_event(
|
emit_game_id_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"game-download-peers-gone",
|
"game-download-peers-gone",
|
||||||
@@ -1678,11 +1668,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(
|
async fn handle_got_game_files(
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
id: String,
|
id: String,
|
||||||
@@ -1697,17 +1682,11 @@ async fn handle_got_game_files(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let state = app_handle.state::<LanSpreadState>();
|
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();
|
let peer_ctrl = state.peer_ctrl.read().await.clone();
|
||||||
if let Some(peer_ctrl) = peer_ctrl
|
if let Some(peer_ctrl) = peer_ctrl
|
||||||
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
|
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
|
||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
account_name,
|
|
||||||
language,
|
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
||||||
|
|||||||
Reference in New Issue
Block a user