feat: store launcher state outside game dirs
Move launcher-owned metadata from game roots into the configured peer state area. Peer identity, the local library index, install intent logs, and setup markers now live under app/CLI state instead of being written beside games. The Tauri shell passes its app data directory into the peer, and the peer CLI runs the same path through its explicit --state-dir. Add a dedicated pre-start migration phase for legacy files. It migrates the old global library index, per-game install intents, and the old first-start marker into app state, then deletes legacy files only after the replacement write succeeds. Normal scan, install, recovery, and transfer paths no longer read legacy state files. Rename the old first-start meaning to setup_done and only set it after launching game_setup.cmd. Start/setup scripts keep the shared argument shape, while server_start.cmd now uses cmd /k and a visible window so server logs stay open for inspection. While validating the Docker scenario matrix, make download terminal events come from the handler after local state refresh and operation cleanup. This makes download-finished/download-failed safe points for immediate follow-up CLI commands. Also update the multi-peer chunking scenario to use a sparse archive large enough to actually span multiple production chunks. Test Plan: - just fmt - just test - just frontend-test - just build - just clippy - git diff --check - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py Refs: local app-state migration discussion
This commit is contained in:
+4
-3
@@ -5,9 +5,10 @@
|
|||||||
directly.
|
directly.
|
||||||
- Renamed the frontend success event to `game-install-finished`; the old
|
- Renamed the frontend success event to `game-install-finished`; the old
|
||||||
unpack name no longer matched the transactional install/update lifecycle.
|
unpack name no longer matched the transactional install/update lifecycle.
|
||||||
- Implemented watcher rescans by reusing the existing `.lanspread/library_index.json`
|
- Implemented watcher rescans by reusing the app-state
|
||||||
cache and updating a single game entry in that index. This satisfies the
|
`local_library/index.json` cache and updating a single game entry in that
|
||||||
per-ID optimized rescan requirement without adding a second cache format.
|
index. This satisfies the per-ID optimized rescan requirement without adding a
|
||||||
|
second cache format.
|
||||||
- Split full startup recovery from ordinary settled refreshes. Startup and real
|
- Split full startup recovery from ordinary settled refreshes. Startup and real
|
||||||
`SetGameDir` changes run recovery plus a scan; install/update/uninstall
|
`SetGameDir` changes run recovery plus a scan; install/update/uninstall
|
||||||
completion only rescans the affected game after operation tracking has been
|
completion only rescans the affected game after operation tracking has been
|
||||||
|
|||||||
@@ -592,27 +592,30 @@ class Runner:
|
|||||||
return "small bfbc2 and large alienswarm transfers both diffed cleanly against sources"
|
return "small bfbc2 and large alienswarm transfers both diffed cleanly against sources"
|
||||||
|
|
||||||
def s14_large_multi_peer_chunking(self) -> str:
|
def s14_large_multi_peer_chunking(self) -> str:
|
||||||
alpha = self.peer("s14-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
game_id = PERF_GAME_ID
|
||||||
|
source_dir = self.fixture_root / "s14-alpha"
|
||||||
|
create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE + 1024 * 1024)
|
||||||
|
alpha = self.peer("s14-alpha", games_dir=source_dir)
|
||||||
stage = self.peer("s14-stage")
|
stage = self.peer("s14-stage")
|
||||||
connect_many(stage, [alpha])
|
connect_many(stage, [alpha])
|
||||||
waiter = LineWaiter(len(stage.output))
|
waiter = LineWaiter(len(stage.output))
|
||||||
stage.send({"cmd": "download", "game_id": "alienswarm", "install": False})
|
stage.send({"cmd": "download", "game_id": game_id, "install": False})
|
||||||
stage.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="stage finish", waiter=waiter)
|
stage.wait_for(event_is("download-finished", game_id), timeout=90, description="stage finish", waiter=waiter)
|
||||||
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", stage.host_games_dir / "alienswarm")
|
diff_game_dirs(source_dir / game_id, stage.host_games_dir / game_id)
|
||||||
client = self.peer("s14-client")
|
client = self.peer("s14-client")
|
||||||
connect_many(client, [alpha, stage])
|
connect_many(client, [alpha, stage])
|
||||||
wait_remote_game(client, "alienswarm", peer_count=2)
|
wait_remote_game(client, game_id, peer_count=2, version="20260520")
|
||||||
waiter = LineWaiter(len(client.output))
|
waiter = LineWaiter(len(client.output))
|
||||||
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
|
client.send({"cmd": "download", "game_id": game_id, "install": False})
|
||||||
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="client finish", waiter=waiter)
|
client.wait_for(event_is("download-finished", game_id), timeout=90, description="client finish", waiter=waiter)
|
||||||
diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", client.host_games_dir / "alienswarm")
|
diff_game_dirs(source_dir / game_id, client.host_games_dir / game_id)
|
||||||
totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti")
|
totals = chunk_totals(client, game_id, f"{game_id}/{game_id}.eti")
|
||||||
if len(totals) < 2:
|
if len(totals) < 2:
|
||||||
raise ScenarioError(f"expected chunks from two peers, got {totals}")
|
raise ScenarioError(f"expected chunks from two peers, got {totals}")
|
||||||
values = list(totals.values())
|
values = list(totals.values())
|
||||||
if max(values) - min(values) > CHUNK_SIZE:
|
if max(values) - min(values) > CHUNK_SIZE:
|
||||||
raise ScenarioError(f"chunk totals not balanced within one chunk: {totals}")
|
raise ScenarioError(f"chunk totals not balanced within one chunk: {totals}")
|
||||||
return f"alienswarm downloaded from two sources, diff matched, chunk totals={totals}"
|
return f"{game_id} downloaded from two sources, diff matched, chunk totals={totals}"
|
||||||
|
|
||||||
def s15_three_way_version_skew(self) -> str:
|
def s15_three_way_version_skew(self) -> str:
|
||||||
specs = [("s15-a", "20250101"), ("s15-b", "20250201"), ("s15-c", "20250301")]
|
specs = [("s15-a", "20250101"), ("s15-b", "20250201"), ("s15-c", "20250301")]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use lanspread_peer::{
|
|||||||
PeerRuntimeHandle,
|
PeerRuntimeHandle,
|
||||||
PeerSnapshot,
|
PeerSnapshot,
|
||||||
PeerStartOptions,
|
PeerStartOptions,
|
||||||
|
migrate_legacy_state,
|
||||||
start_peer_with_options,
|
start_peer_with_options,
|
||||||
};
|
};
|
||||||
use lanspread_peer_cli::{
|
use lanspread_peer_cli::{
|
||||||
@@ -125,6 +126,7 @@ async fn main() -> eyre::Result<()> {
|
|||||||
|
|
||||||
let fixture_seeds = seed_fixtures(&args.games_dir, &args.fixtures)?;
|
let fixture_seeds = seed_fixtures(&args.games_dir, &args.fixtures)?;
|
||||||
let catalog = load_catalog(args.catalog_db.as_deref(), &fixture_seeds).await;
|
let catalog = load_catalog(args.catalog_db.as_deref(), &fixture_seeds).await;
|
||||||
|
let migration = migrate_legacy_state(&args.games_dir, &args.state_dir).await;
|
||||||
|
|
||||||
let (tx_events, rx_events) = mpsc::unbounded_channel();
|
let (tx_events, rx_events) = mpsc::unbounded_channel();
|
||||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||||
@@ -162,6 +164,7 @@ async fn main() -> eyre::Result<()> {
|
|||||||
"name": args.name,
|
"name": args.name,
|
||||||
"games_dir": args.games_dir,
|
"games_dir": args.games_dir,
|
||||||
"state_dir": args.state_dir,
|
"state_dir": args.state_dir,
|
||||||
|
"migration": migration,
|
||||||
"fixtures": fixture_seeds,
|
"fixtures": fixture_seeds,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ Reserved per-game paths:
|
|||||||
- `.local.installing/` is extraction staging.
|
- `.local.installing/` is extraction staging.
|
||||||
- `.local.backup/` holds the previous install while an update or uninstall is in
|
- `.local.backup/` holds the previous install while an update or uninstall is in
|
||||||
flight.
|
flight.
|
||||||
- `.lanspread.json` is the atomic per-game intent log.
|
- `games/<game_id>/install_intent.json` in the configured state directory is the
|
||||||
|
atomic per-game intent log.
|
||||||
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
|
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
|
||||||
when the current intent is `None`.
|
when the current intent is `None`.
|
||||||
|
|
||||||
@@ -133,11 +134,17 @@ game root only for a catalog ID that is a single direct child of the configured
|
|||||||
game directory, has a regular root-level `version.ini`, and has no `local/`,
|
game directory, has a regular root-level `version.ini`, and has no `local/`,
|
||||||
`.local.installing/`, or `.local.backup/` path.
|
`.local.installing/`, or `.local.backup/` path.
|
||||||
|
|
||||||
Recovery reads `.lanspread.json` and combines the recorded intent with the
|
Recovery reads app-state `install_intent.json` and combines the recorded intent
|
||||||
observed `local/`, `.local.installing/`, and `.local.backup/` state. Intent
|
with the observed `local/`, `.local.installing/`, and `.local.backup/` state.
|
||||||
states `Installing`, `Updating`, and `Uninstalling` prove ownership of the
|
Intent states `Installing`, `Updating`, and `Uninstalling` prove ownership of
|
||||||
corresponding reserved directories even if the marker was not flushed before a
|
the corresponding reserved directories even if the marker was not flushed before
|
||||||
crash. With intent `None`, markerless `.local.*` directories are left untouched.
|
a crash. With intent `None`, markerless `.local.*` directories are left
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
Legacy `.lanspread/`, `.lanspread.json`, `.lanspread.json.tmp`,
|
||||||
|
`.softlan_game_installed`, and `local/.softlan_first_start_done` files are
|
||||||
|
handled only by the dedicated pre-start migration phase. Normal operation does
|
||||||
|
not read legacy state paths.
|
||||||
|
|
||||||
### Result
|
### Result
|
||||||
|
|
||||||
@@ -195,8 +202,8 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
|||||||
- Cache the last accepted `manifest_hash` per peer to short-circuit
|
- Cache the last accepted `manifest_hash` per peer to short-circuit
|
||||||
manifest requests when unchanged.
|
manifest requests when unchanged.
|
||||||
5. Local index + scan optimizations:
|
5. Local index + scan optimizations:
|
||||||
- Introduce a cached index file (e.g., `.lanspread/index.json`) that stores
|
- Use the cached `local_library/index.json` file in the configured state
|
||||||
per-root fingerprints and computed manifests.
|
directory to store per-root fingerprints and computed manifests.
|
||||||
- Use filesystem watchers with a debounce window to collect changes and
|
- Use filesystem watchers with a debounce window to collect changes and
|
||||||
incrementally update the cache.
|
incrementally update the cache.
|
||||||
- Schedule a low-frequency full scan to reconcile missed watcher events.
|
- Schedule a low-frequency full scan to reconcile missed watcher events.
|
||||||
|
|||||||
@@ -97,16 +97,21 @@ truth for whether a download is still running.
|
|||||||
|
|
||||||
Install, update, uninstall, downloaded-file removal, and startup recovery live
|
Install, update, uninstall, downloaded-file removal, and startup recovery live
|
||||||
under `src/install/`.
|
under `src/install/`.
|
||||||
Each game root has an atomic `.lanspread.json` intent log for install-side
|
Install-side operation intent is stored atomically under the configured peer
|
||||||
operations and uses Lanspread-owned `.local.installing/` and `.local.backup/`
|
state directory, at `games/<game_id>/install_intent.json`. Game roots still use
|
||||||
directories marked by `.lanspread_owned`. Startup recovery combines the recorded
|
Lanspread-owned `.local.installing/` and `.local.backup/` directories marked by
|
||||||
intent with the observed filesystem state and only deletes reserved directories
|
`.lanspread_owned`. Startup recovery combines the recorded intent with the
|
||||||
when intent or marker ownership proves they belong to Lanspread.
|
observed filesystem state and only deletes reserved directories when intent or
|
||||||
|
marker ownership proves they belong to Lanspread.
|
||||||
Downloaded-file removal is deliberately separate from uninstall: it only accepts
|
Downloaded-file removal is deliberately separate from uninstall: it only accepts
|
||||||
catalog IDs that are direct children of the configured game directory, refuses
|
catalog IDs that are direct children of the configured game directory, refuses
|
||||||
installed or in-flight roots, and deletes the whole game root only after finding
|
installed or in-flight roots, and deletes the whole game root only after finding
|
||||||
a regular root-level `version.ini` sentinel.
|
a regular root-level `version.ini` sentinel.
|
||||||
|
|
||||||
|
Legacy launcher-owned files in game directories are migrated by a dedicated
|
||||||
|
pre-start phase. Normal install, recovery, scan, and transfer paths use only the
|
||||||
|
configured state directory for launcher-owned metadata.
|
||||||
|
|
||||||
## Integration with `lanspread-tauri-deno-ts`
|
## Integration with `lanspread-tauri-deno-ts`
|
||||||
|
|
||||||
The Tauri application embeds this crate in
|
The Tauri application embeds this crate in
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub enum OperationKind {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Ctx {
|
pub struct Ctx {
|
||||||
pub game_dir: Arc<RwLock<PathBuf>>,
|
pub game_dir: Arc<RwLock<PathBuf>>,
|
||||||
|
pub state_dir: Arc<PathBuf>,
|
||||||
pub local_game_db: Arc<RwLock<Option<GameDB>>>,
|
pub local_game_db: Arc<RwLock<Option<GameDB>>>,
|
||||||
pub local_library: Arc<RwLock<LocalLibraryState>>,
|
pub local_library: Arc<RwLock<LocalLibraryState>>,
|
||||||
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
|
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
@@ -79,6 +80,7 @@ impl Ctx {
|
|||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
peer_id: String,
|
peer_id: String,
|
||||||
game_dir: PathBuf,
|
game_dir: PathBuf,
|
||||||
|
state_dir: PathBuf,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
task_tracker: TaskTracker,
|
task_tracker: TaskTracker,
|
||||||
@@ -86,6 +88,7 @@ impl Ctx {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
game_dir: Arc::new(RwLock::new(game_dir)),
|
game_dir: Arc::new(RwLock::new(game_dir)),
|
||||||
|
state_dir: Arc::new(state_dir),
|
||||||
local_game_db: Arc::new(RwLock::new(None)),
|
local_game_db: Arc::new(RwLock::new(None)),
|
||||||
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
|
local_library: Arc::new(RwLock::new(LocalLibraryState::empty())),
|
||||||
peer_game_db,
|
peer_game_db,
|
||||||
|
|||||||
@@ -43,25 +43,14 @@ pub async fn download_game_files(
|
|||||||
eyre::bail!("download cancelled for game {game_id}");
|
eyre::bail!("download cancelled for game {game_id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let (version_desc, transfer_descs) =
|
let (version_desc, transfer_descs) = extract_version_descriptor(game_id, game_file_descs)?;
|
||||||
extract_version_descriptor(game_id, game_file_descs, &tx_notify_ui)?;
|
|
||||||
let version_buffer = match VersionIniBuffer::new(&version_desc) {
|
let version_buffer = match VersionIniBuffer::new(&version_desc) {
|
||||||
Ok(buffer) => Arc::new(buffer),
|
Ok(buffer) => Arc::new(buffer),
|
||||||
Err(err) => {
|
Err(err) => return Err(err),
|
||||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
|
||||||
id: game_id.to_string(),
|
|
||||||
})?;
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let game_root = games_folder.join(game_id);
|
let game_root = games_folder.join(game_id);
|
||||||
|
|
||||||
if let Err(err) = begin_version_ini_transaction(&game_root).await {
|
begin_version_ini_transaction(&game_root).await?;
|
||||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
|
||||||
id: game_id.to_string(),
|
|
||||||
})?;
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
if cancel_token.is_cancelled() {
|
if cancel_token.is_cancelled() {
|
||||||
rollback_version_ini_transaction(&game_root).await;
|
rollback_version_ini_transaction(&game_root).await;
|
||||||
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||||
@@ -73,9 +62,6 @@ pub async fn download_game_files(
|
|||||||
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||||
eyre::bail!("download cancelled for game {game_id}");
|
eyre::bail!("download cancelled for game {game_id}");
|
||||||
}
|
}
|
||||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
|
||||||
id: game_id.to_string(),
|
|
||||||
})?;
|
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
if cancel_token.is_cancelled() {
|
if cancel_token.is_cancelled() {
|
||||||
@@ -111,10 +97,6 @@ pub async fn download_game_files(
|
|||||||
rollback_version_ini_transaction(&game_root).await;
|
rollback_version_ini_transaction(&game_root).await;
|
||||||
if cancel_token.is_cancelled() {
|
if cancel_token.is_cancelled() {
|
||||||
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
discard_cancelled_download_best_effort(&games_folder, game_id).await;
|
||||||
} else {
|
|
||||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
|
||||||
id: game_id.to_string(),
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
@@ -127,15 +109,9 @@ pub async fn download_game_files(
|
|||||||
|
|
||||||
if let Err(err) = commit_version_ini_buffer(&game_root, &version_buffer).await {
|
if let Err(err) = commit_version_ini_buffer(&game_root, &version_buffer).await {
|
||||||
rollback_version_ini_transaction(&game_root).await;
|
rollback_version_ini_transaction(&game_root).await;
|
||||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
|
||||||
id: game_id.to_string(),
|
|
||||||
})?;
|
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
log::info!("all files downloaded for game: {game_id}");
|
log::info!("all files downloaded for game: {game_id}");
|
||||||
tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished {
|
|
||||||
id: game_id.to_string(),
|
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use std::{collections::HashMap, net::SocketAddr};
|
use std::{collections::HashMap, net::SocketAddr};
|
||||||
|
|
||||||
use lanspread_db::db::GameFileDescription;
|
use lanspread_db::db::GameFileDescription;
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
|
||||||
|
|
||||||
use crate::{PeerEvent, config::CHUNK_SIZE};
|
use crate::config::CHUNK_SIZE;
|
||||||
|
|
||||||
/// Represents a chunk of a file to be downloaded.
|
/// Represents a chunk of a file to be downloaded.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -34,7 +33,6 @@ pub(super) struct ChunkDownloadResult {
|
|||||||
pub(super) fn extract_version_descriptor(
|
pub(super) fn extract_version_descriptor(
|
||||||
game_id: &str,
|
game_id: &str,
|
||||||
game_file_descs: Vec<GameFileDescription>,
|
game_file_descs: Vec<GameFileDescription>,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
||||||
) -> eyre::Result<(GameFileDescription, Vec<GameFileDescription>)> {
|
) -> eyre::Result<(GameFileDescription, Vec<GameFileDescription>)> {
|
||||||
let mut version_descs = Vec::new();
|
let mut version_descs = Vec::new();
|
||||||
let mut transfer_descs = Vec::new();
|
let mut transfer_descs = Vec::new();
|
||||||
@@ -47,9 +45,6 @@ pub(super) fn extract_version_descriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if version_descs.len() != 1 {
|
if version_descs.len() != 1 {
|
||||||
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
|
|
||||||
id: game_id.to_string(),
|
|
||||||
});
|
|
||||||
eyre::bail!(
|
eyre::bail!(
|
||||||
"expected exactly one root-level version.ini sentinel for {game_id}, found {}",
|
"expected exactly one root-level version.ini sentinel for {game_id}, found {}",
|
||||||
version_descs.len()
|
version_descs.len()
|
||||||
@@ -296,7 +291,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() {
|
fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() {
|
||||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let nested_decoy = vec![
|
let nested_decoy = vec![
|
||||||
GameFileDescription {
|
GameFileDescription {
|
||||||
game_id: "game".to_string(),
|
game_id: "game".to_string(),
|
||||||
@@ -313,26 +307,24 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let (version, transfer) =
|
let (version, transfer) =
|
||||||
extract_version_descriptor("game", nested_decoy, &tx).expect("only one root sentinel");
|
extract_version_descriptor("game", nested_decoy).expect("only one root sentinel");
|
||||||
assert_eq!(version.relative_path, "game/version.ini");
|
assert_eq!(version.relative_path, "game/version.ini");
|
||||||
assert_eq!(transfer.len(), 2);
|
assert_eq!(transfer.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_descriptor_extraction_requires_a_root_version_ini() {
|
fn version_descriptor_extraction_requires_a_root_version_ini() {
|
||||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let missing = vec![GameFileDescription {
|
let missing = vec![GameFileDescription {
|
||||||
game_id: "game".to_string(),
|
game_id: "game".to_string(),
|
||||||
relative_path: "game/archive.eti".to_string(),
|
relative_path: "game/archive.eti".to_string(),
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
size: 1,
|
size: 1,
|
||||||
}];
|
}];
|
||||||
assert!(extract_version_descriptor("game", missing, &tx).is_err());
|
assert!(extract_version_descriptor("game", missing).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_descriptor_extraction_rejects_duplicate_root_version_ini() {
|
fn version_descriptor_extraction_rejects_duplicate_root_version_ini() {
|
||||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let multiple = vec![
|
let multiple = vec![
|
||||||
GameFileDescription {
|
GameFileDescription {
|
||||||
game_id: "game".to_string(),
|
game_id: "game".to_string(),
|
||||||
@@ -347,6 +339,6 @@ mod tests {
|
|||||||
size: 8,
|
size: 8,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
assert!(extract_version_descriptor("game", multiple, &tx).is_err());
|
assert!(extract_version_descriptor("game", multiple).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ use tokio::fs::OpenOptions;
|
|||||||
|
|
||||||
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
|
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
|
||||||
|
|
||||||
const INTENT_LOG_FILE: &str = ".lanspread.json";
|
|
||||||
const SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
|
|
||||||
const SYNC_DIR: &str = ".sync";
|
const SYNC_DIR: &str = ".sync";
|
||||||
|
|
||||||
/// Prepares storage for game files by creating directories and pre-allocating files.
|
/// Prepares storage for game files by creating directories and pre-allocating files.
|
||||||
@@ -99,11 +97,7 @@ pub(super) async fn discard_cancelled_download(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn should_preserve_on_download_discard(name: &str) -> bool {
|
fn should_preserve_on_download_discard(name: &str) -> bool {
|
||||||
is_local_dir_name(name)
|
is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR
|
||||||
|| name.starts_with(".local.")
|
|
||||||
|| name == INTENT_LOG_FILE
|
|
||||||
|| name == SOFTLAN_INSTALL_MARKER
|
|
||||||
|| name == SYNC_DIR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_entry(path: &Path) -> eyre::Result<()> {
|
async fn remove_entry(path: &Path) -> eyre::Result<()> {
|
||||||
@@ -207,7 +201,6 @@ mod tests {
|
|||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
write_file(&root.join("archive.eti"), b"partial");
|
write_file(&root.join("archive.eti"), b"partial");
|
||||||
write_file(&root.join("local").join("save.dat"), b"user-data");
|
write_file(&root.join("local").join("save.dat"), b"user-data");
|
||||||
write_file(&root.join(".lanspread.json"), b"{\"intent\":\"None\"}");
|
|
||||||
write_file(&root.join(".local.backup").join(".lanspread_owned"), b"");
|
write_file(&root.join(".local.backup").join(".lanspread_owned"), b"");
|
||||||
|
|
||||||
discard_cancelled_download(temp.path(), "game")
|
discard_cancelled_download(temp.path(), "game")
|
||||||
@@ -221,7 +214,6 @@ mod tests {
|
|||||||
.expect("local install should remain"),
|
.expect("local install should remain"),
|
||||||
b"user-data"
|
b"user-data"
|
||||||
);
|
);
|
||||||
assert!(root.join(".lanspread.json").is_file());
|
|
||||||
assert!(root.join(".local.backup").is_dir());
|
assert!(root.join(".local.backup").is_dir());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ pub async fn handle_download_game_files_command(
|
|||||||
}
|
}
|
||||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||||
download_state_guard.disarm();
|
download_state_guard.disarm();
|
||||||
|
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -354,6 +355,8 @@ pub async fn handle_download_game_files_command(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
clear_active_download(&ctx_clone, &download_id).await;
|
clear_active_download(&ctx_clone, &download_id).await;
|
||||||
|
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||||
|
download_state_guard.disarm();
|
||||||
run_started_install_operation(
|
run_started_install_operation(
|
||||||
&ctx_clone,
|
&ctx_clone,
|
||||||
&tx_notify_ui_clone,
|
&tx_notify_ui_clone,
|
||||||
@@ -362,7 +365,9 @@ pub async fn handle_download_game_files_command(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
clear_active_download(&ctx_clone, &download_id).await;
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||||
|
download_state_guard.disarm();
|
||||||
|
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if let Err(err) = refresh_local_game_for_ending_operation(
|
if let Err(err) = refresh_local_game_for_ending_operation(
|
||||||
@@ -375,8 +380,9 @@ pub async fn handle_download_game_files_command(
|
|||||||
log::error!("Failed to refresh local library after download: {err}");
|
log::error!("Failed to refresh local library after download: {err}");
|
||||||
}
|
}
|
||||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||||
}
|
|
||||||
download_state_guard.disarm();
|
download_state_guard.disarm();
|
||||||
|
send_download_finished(&tx_notify_ui_clone, &download_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let Err(refresh_err) = refresh_local_game_for_ending_operation(
|
if let Err(refresh_err) = refresh_local_game_for_ending_operation(
|
||||||
@@ -393,6 +399,7 @@ pub async fn handle_download_game_files_command(
|
|||||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||||
download_state_guard.disarm();
|
download_state_guard.disarm();
|
||||||
log::error!("Download failed for {download_id}: {e}");
|
log::error!("Download failed for {download_id}: {e}");
|
||||||
|
send_download_failed(&tx_notify_ui_clone, &download_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -538,12 +545,13 @@ async fn run_started_install_operation(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let state_dir = ctx.state_dir.as_ref();
|
||||||
match operation {
|
match operation {
|
||||||
InstallOperation::Installing => {
|
InstallOperation::Installing => {
|
||||||
install::install(&game_root, &id, ctx.unpacker.clone()).await
|
install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||||
}
|
}
|
||||||
InstallOperation::Updating => {
|
InstallOperation::Updating => {
|
||||||
install::update(&game_root, &id, ctx.unpacker.clone()).await
|
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -601,7 +609,7 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
|||||||
PeerEvent::UninstallGameBegin { id: id.clone() },
|
PeerEvent::UninstallGameBegin { id: id.clone() },
|
||||||
);
|
);
|
||||||
|
|
||||||
install::uninstall(&game_root, &id).await
|
install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -767,6 +775,18 @@ async fn clear_active_download(ctx: &Ctx, id: &str) {
|
|||||||
ctx.active_downloads.write().await.remove(id);
|
ctx.active_downloads.write().await.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn send_download_finished(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||||
|
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.into() }) {
|
||||||
|
log::error!("Failed to send DownloadGameFilesFinished event: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_download_failed(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||||
|
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.into() }) {
|
||||||
|
log::error!("Failed to send DownloadGameFilesFailed event: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||||
end_operation(ctx, tx_notify_ui, id).await;
|
end_operation(ctx, tx_notify_ui, id).await;
|
||||||
clear_active_download(ctx, id).await;
|
clear_active_download(ctx, id).await;
|
||||||
@@ -845,7 +865,7 @@ async fn load_local_library_with_policy(
|
|||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||||
let active_ids = active_operation_ids(ctx).await;
|
let active_ids = active_operation_ids(ctx).await;
|
||||||
install::recover_on_startup(&game_dir, &active_ids).await?;
|
install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?;
|
||||||
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await
|
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,7 +890,7 @@ async fn scan_and_announce_local_library(
|
|||||||
event_policy: LocalLibraryEventPolicy,
|
event_policy: LocalLibraryEventPolicy,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
let scan = scan_local_library(game_dir, &catalog).await?;
|
let scan = scan_local_library(game_dir, ctx.state_dir.as_ref(), &catalog).await?;
|
||||||
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
|
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -884,7 +904,7 @@ async fn refresh_local_game_for_ending_operation(
|
|||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
|
let scan = rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, id).await?;
|
||||||
update_and_announce_games_with_policy(
|
update_and_announce_games_with_policy(
|
||||||
ctx,
|
ctx,
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
@@ -1068,7 +1088,8 @@ mod tests {
|
|||||||
Ctx::new(
|
Ctx::new(
|
||||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||||
"peer".to_string(),
|
"peer".to_string(),
|
||||||
game_dir,
|
game_dir.clone(),
|
||||||
|
game_dir.join(".test-state"),
|
||||||
Arc::new(FakeUnpacker),
|
Arc::new(FakeUnpacker),
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
TaskTracker::new(),
|
TaskTracker::new(),
|
||||||
@@ -1332,7 +1353,7 @@ mod tests {
|
|||||||
.insert("game".to_string(), OperationKind::Installing);
|
.insert("game".to_string(), OperationKind::Installing);
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
let scan = scan_local_library(temp.path(), &catalog)
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("scan should succeed");
|
.expect("scan should succeed");
|
||||||
|
|
||||||
@@ -1378,13 +1399,13 @@ mod tests {
|
|||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
|
|
||||||
let scan = scan_local_library(temp.path(), &catalog)
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("first scan should succeed");
|
.expect("first scan should succeed");
|
||||||
update_and_announce_games(&ctx, &tx, scan).await;
|
update_and_announce_games(&ctx, &tx, scan).await;
|
||||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||||
|
|
||||||
let scan = scan_local_library(temp.path(), &catalog)
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("second scan should succeed");
|
.expect("second scan should succeed");
|
||||||
update_and_announce_games(&ctx, &tx, scan).await;
|
update_and_announce_games(&ctx, &tx, scan).await;
|
||||||
@@ -1403,7 +1424,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();
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
let scan = scan_local_library(temp.path(), &catalog)
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("initial scan should succeed");
|
.expect("initial scan should succeed");
|
||||||
update_and_announce_games(&ctx, &tx, scan).await;
|
update_and_announce_games(&ctx, &tx, scan).await;
|
||||||
@@ -1695,7 +1716,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();
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
let scan = scan_local_library(temp.path(), &catalog)
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("initial scan should succeed");
|
.expect("initial scan should succeed");
|
||||||
update_and_announce_games(&ctx, &tx, scan).await;
|
update_and_announce_games(&ctx, &tx, scan).await;
|
||||||
@@ -1781,7 +1802,7 @@ mod tests {
|
|||||||
let ctx = test_ctx(current.path().to_path_buf());
|
let ctx = test_ctx(current.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
let scan = scan_local_library(current.path(), &catalog)
|
let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("initial scan should succeed");
|
.expect("initial scan should succeed");
|
||||||
update_and_announce_games(&ctx, &tx, scan).await;
|
update_and_announce_games(&ctx, &tx, scan).await;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const PEER_ID_FILE: &str = "peer_id";
|
use crate::state_paths::peer_id_path;
|
||||||
|
|
||||||
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
|
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
|
||||||
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
|
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
|
||||||
|
|
||||||
pub fn load_or_create_peer_id(state_dir: Option<&Path>) -> eyre::Result<String> {
|
pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result<String> {
|
||||||
let path = peer_id_path(state_dir);
|
let path = peer_id_path(state_dir);
|
||||||
if let Ok(existing) = std::fs::read_to_string(&path) {
|
if let Ok(existing) = std::fs::read_to_string(&path) {
|
||||||
let trimmed = existing.trim();
|
let trimmed = existing.trim();
|
||||||
@@ -30,19 +30,3 @@ pub fn default_features() -> Vec<String> {
|
|||||||
FEATURE_LIBRARY_SNAPSHOT.to_string(),
|
FEATURE_LIBRARY_SNAPSHOT.to_string(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn peer_id_path(state_dir: Option<&Path>) -> PathBuf {
|
|
||||||
if let Some(dir) = state_dir {
|
|
||||||
return dir.join(PEER_ID_FILE);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") {
|
|
||||||
return PathBuf::from(dir).join(PEER_ID_FILE);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
|
|
||||||
return PathBuf::from(home).join(".lanspread").join(PEER_ID_FILE);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::env::temp_dir().join("lanspread").join(PEER_ID_FILE)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
const INTENT_SCHEMA_VERSION: u32 = 1;
|
const INTENT_SCHEMA_VERSION: u32 = 1;
|
||||||
const INTENT_FILE: &str = ".lanspread.json";
|
pub(crate) const LEGACY_INTENT_FILE: &str = ".lanspread.json";
|
||||||
const INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
|
pub(crate) const LEGACY_INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
|
||||||
|
const INTENT_FILE: &str = "install_intent.json";
|
||||||
|
const INTENT_TMP_FILE: &str = "install_intent.json.tmp";
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub enum InstallIntentState {
|
pub enum InstallIntentState {
|
||||||
@@ -41,18 +43,22 @@ impl InstallIntent {
|
|||||||
pub fn none(id: &str, eti_version: Option<String>) -> Self {
|
pub fn none(id: &str, eti_version: Option<String>) -> Self {
|
||||||
Self::new(id, InstallIntentState::None, eti_version)
|
Self::new(id, InstallIntentState::None, eti_version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_current_for(&self, id: &str) -> bool {
|
||||||
|
self.schema_version == INTENT_SCHEMA_VERSION && self.id == id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn intent_path(game_root: &Path) -> PathBuf {
|
pub fn intent_path(state_dir: &Path, id: &str) -> PathBuf {
|
||||||
game_root.join(INTENT_FILE)
|
crate::state_paths::game_state_dir(state_dir, id).join(INTENT_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn intent_tmp_path(game_root: &Path) -> PathBuf {
|
pub fn intent_tmp_path(state_dir: &Path, id: &str) -> PathBuf {
|
||||||
game_root.join(INTENT_TMP_FILE)
|
crate::state_paths::game_state_dir(state_dir, id).join(INTENT_TMP_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
pub async fn read_intent(state_dir: &Path, id: &str) -> InstallIntent {
|
||||||
let path = intent_path(game_root);
|
let path = intent_path(state_dir, id);
|
||||||
let data = match tokio::fs::read_to_string(&path).await {
|
let data = match tokio::fs::read_to_string(&path).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -64,7 +70,7 @@ pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match serde_json::from_str::<InstallIntent>(&data) {
|
match serde_json::from_str::<InstallIntent>(&data) {
|
||||||
Ok(intent) if intent.schema_version == INTENT_SCHEMA_VERSION && intent.id == id => intent,
|
Ok(intent) if intent.is_current_for(id) => intent,
|
||||||
Ok(intent) => {
|
Ok(intent) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Ignoring install intent {} with schema {} for id {}",
|
"Ignoring install intent {} with schema {} for id {}",
|
||||||
@@ -81,10 +87,11 @@ pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn write_intent(game_root: &Path, intent: &InstallIntent) -> eyre::Result<()> {
|
pub async fn write_intent(state_dir: &Path, id: &str, intent: &InstallIntent) -> eyre::Result<()> {
|
||||||
tokio::fs::create_dir_all(game_root).await?;
|
let game_state_dir = crate::state_paths::game_state_dir(state_dir, id);
|
||||||
let path = intent_path(game_root);
|
tokio::fs::create_dir_all(&game_state_dir).await?;
|
||||||
let tmp_path = intent_tmp_path(game_root);
|
let path = intent_path(state_dir, id);
|
||||||
|
let tmp_path = intent_tmp_path(state_dir, id);
|
||||||
let data = serde_json::to_vec_pretty(intent)?;
|
let data = serde_json::to_vec_pretty(intent)?;
|
||||||
|
|
||||||
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||||
@@ -122,6 +129,18 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_support::TempDir;
|
use crate::test_support::TempDir;
|
||||||
|
|
||||||
|
async fn write_raw_intent(state_dir: &Path, id: &str, bytes: impl AsRef<[u8]>) {
|
||||||
|
let path = intent_path(state_dir, id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.expect("intent parent should be created");
|
||||||
|
}
|
||||||
|
tokio::fs::write(path, bytes)
|
||||||
|
.await
|
||||||
|
.expect("intent should be written");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tmp_write_without_rename_leaves_previous_intent_intact() {
|
async fn tmp_write_without_rename_leaves_previous_intent_intact() {
|
||||||
let temp = TempDir::new("lanspread-intent");
|
let temp = TempDir::new("lanspread-intent");
|
||||||
@@ -130,12 +149,12 @@ mod tests {
|
|||||||
InstallIntentState::Updating,
|
InstallIntentState::Updating,
|
||||||
Some("20240101".to_string()),
|
Some("20240101".to_string()),
|
||||||
);
|
);
|
||||||
write_intent(temp.path(), &previous)
|
write_intent(temp.path(), "game", &previous)
|
||||||
.await
|
.await
|
||||||
.expect("previous intent should be written");
|
.expect("previous intent should be written");
|
||||||
|
|
||||||
tokio::fs::write(
|
tokio::fs::write(
|
||||||
intent_tmp_path(temp.path()),
|
intent_tmp_path(temp.path(), "game"),
|
||||||
serde_json::to_vec(&InstallIntent::new(
|
serde_json::to_vec(&InstallIntent::new(
|
||||||
"game",
|
"game",
|
||||||
InstallIntentState::Installing,
|
InstallIntentState::Installing,
|
||||||
@@ -154,12 +173,12 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn schema_mismatch_is_treated_as_missing() {
|
async fn schema_mismatch_is_treated_as_missing() {
|
||||||
let temp = TempDir::new("lanspread-intent");
|
let temp = TempDir::new("lanspread-intent");
|
||||||
tokio::fs::write(
|
write_raw_intent(
|
||||||
intent_path(temp.path()),
|
temp.path(),
|
||||||
|
"game",
|
||||||
r#"{"schema_version":2,"id":"game","recorded_at":0,"state":"Updating"}"#,
|
r#"{"schema_version":2,"id":"game","recorded_at":0,"state":"Updating"}"#,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.expect("intent should be written");
|
|
||||||
|
|
||||||
let recovered = read_intent(temp.path(), "game").await;
|
let recovered = read_intent(temp.path(), "game").await;
|
||||||
assert_eq!(recovered.state, InstallIntentState::None);
|
assert_eq!(recovered.state, InstallIntentState::None);
|
||||||
@@ -168,12 +187,12 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mismatched_id_is_treated_as_missing() {
|
async fn mismatched_id_is_treated_as_missing() {
|
||||||
let temp = TempDir::new("lanspread-intent");
|
let temp = TempDir::new("lanspread-intent");
|
||||||
tokio::fs::write(
|
write_raw_intent(
|
||||||
intent_path(temp.path()),
|
temp.path(),
|
||||||
|
"game",
|
||||||
r#"{"schema_version":1,"id":"other","recorded_at":0,"state":"Updating"}"#,
|
r#"{"schema_version":1,"id":"other","recorded_at":0,"state":"Updating"}"#,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.expect("intent should be written");
|
|
||||||
|
|
||||||
let recovered = read_intent(temp.path(), "game").await;
|
let recovered = read_intent(temp.path(), "game").await;
|
||||||
assert_eq!(recovered.state, InstallIntentState::None);
|
assert_eq!(recovered.state, InstallIntentState::None);
|
||||||
@@ -182,9 +201,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn corrupt_intent_is_treated_as_missing() {
|
async fn corrupt_intent_is_treated_as_missing() {
|
||||||
let temp = TempDir::new("lanspread-intent");
|
let temp = TempDir::new("lanspread-intent");
|
||||||
tokio::fs::write(intent_path(temp.path()), b"not json")
|
write_raw_intent(temp.path(), "game", b"not json").await;
|
||||||
.await
|
|
||||||
.expect("intent should be written");
|
|
||||||
|
|
||||||
let recovered = read_intent(temp.path(), "game").await;
|
let recovered = read_intent(temp.path(), "game").await;
|
||||||
assert_eq!(recovered.state, InstallIntentState::None);
|
assert_eq!(recovered.state, InstallIntentState::None);
|
||||||
@@ -193,21 +210,21 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn old_manifest_hash_field_is_ignored_and_new_writes_omit_it() {
|
async fn old_manifest_hash_field_is_ignored_and_new_writes_omit_it() {
|
||||||
let temp = TempDir::new("lanspread-intent");
|
let temp = TempDir::new("lanspread-intent");
|
||||||
tokio::fs::write(
|
write_raw_intent(
|
||||||
intent_path(temp.path()),
|
temp.path(),
|
||||||
|
"game",
|
||||||
r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":"20240101","manifest_hash":42}"#,
|
r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":"20240101","manifest_hash":42}"#,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.expect("intent should be written");
|
|
||||||
|
|
||||||
let recovered = read_intent(temp.path(), "game").await;
|
let recovered = read_intent(temp.path(), "game").await;
|
||||||
assert_eq!(recovered.state, InstallIntentState::Updating);
|
assert_eq!(recovered.state, InstallIntentState::Updating);
|
||||||
assert_eq!(recovered.eti_version.as_deref(), Some("20240101"));
|
assert_eq!(recovered.eti_version.as_deref(), Some("20240101"));
|
||||||
|
|
||||||
write_intent(temp.path(), &InstallIntent::none("game", None))
|
write_intent(temp.path(), "game", &InstallIntent::none("game", None))
|
||||||
.await
|
.await
|
||||||
.expect("intent should be written");
|
.expect("intent should be written");
|
||||||
let written = tokio::fs::read_to_string(intent_path(temp.path()))
|
let written = tokio::fs::read_to_string(intent_path(temp.path(), "game"))
|
||||||
.await
|
.await
|
||||||
.expect("intent should be readable");
|
.expect("intent should be readable");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
mod intent;
|
pub(crate) mod intent;
|
||||||
mod remove;
|
mod remove;
|
||||||
mod transaction;
|
mod transaction;
|
||||||
pub mod unpack;
|
pub mod unpack;
|
||||||
|
|||||||
@@ -33,10 +33,16 @@ struct InstallFsState {
|
|||||||
backup: FsEntryState,
|
backup: FsEntryState,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
pub async fn install(
|
||||||
|
game_root: &Path,
|
||||||
|
state_dir: &Path,
|
||||||
|
id: &str,
|
||||||
|
unpacker: Arc<dyn Unpacker>,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
let eti_version = read_downloaded_version(game_root).await;
|
let eti_version = read_downloaded_version(game_root).await;
|
||||||
write_intent(
|
write_intent(
|
||||||
game_root,
|
state_dir,
|
||||||
|
id,
|
||||||
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
|
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -44,7 +50,7 @@ pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
|||||||
let result = install_inner(game_root, id, unpacker).await;
|
let result = install_inner(game_root, id, unpacker).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -54,16 +60,22 @@ pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
|||||||
installing_dir(game_root).display()
|
installing_dir(game_root).display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||||
Err(err)
|
Err(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
pub async fn update(
|
||||||
|
game_root: &Path,
|
||||||
|
state_dir: &Path,
|
||||||
|
id: &str,
|
||||||
|
unpacker: Arc<dyn Unpacker>,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
let eti_version = read_downloaded_version(game_root).await;
|
let eti_version = read_downloaded_version(game_root).await;
|
||||||
write_intent(
|
write_intent(
|
||||||
game_root,
|
state_dir,
|
||||||
|
id,
|
||||||
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
|
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -71,7 +83,7 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
|||||||
let result = update_inner(game_root, id, unpacker).await;
|
let result = update_inner(game_root, id, unpacker).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).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 {
|
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}",
|
||||||
@@ -82,7 +94,7 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let rollback = rollback_update(game_root).await;
|
let rollback = rollback_update(game_root).await;
|
||||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||||
if let Err(rollback_err) = rollback {
|
if let Err(rollback_err) = rollback {
|
||||||
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
|
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
|
||||||
}
|
}
|
||||||
@@ -91,10 +103,11 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
pub async fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
|
||||||
let eti_version = read_downloaded_version(game_root).await;
|
let eti_version = read_downloaded_version(game_root).await;
|
||||||
write_intent(
|
write_intent(
|
||||||
game_root,
|
state_dir,
|
||||||
|
id,
|
||||||
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
|
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -102,7 +115,7 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
|||||||
let result = uninstall_inner(game_root).await;
|
let result = uninstall_inner(game_root).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -110,13 +123,17 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
|||||||
if let Err(rollback_err) = rollback {
|
if let Err(rollback_err) = rollback {
|
||||||
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
|
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
|
||||||
}
|
}
|
||||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||||
Err(err)
|
Err(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -> eyre::Result<()> {
|
pub async fn recover_on_startup(
|
||||||
|
game_dir: &Path,
|
||||||
|
state_dir: &Path,
|
||||||
|
active_ids: &HashSet<String>,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
recover_download_transients(game_dir).await?;
|
recover_download_transients(game_dir).await?;
|
||||||
|
|
||||||
let mut entries = match tokio::fs::read_dir(game_dir).await {
|
let mut entries = match tokio::fs::read_dir(game_dir).await {
|
||||||
@@ -141,22 +158,28 @@ pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
recover_game_root(&entry.path(), &id).await?;
|
recover_game_root(&entry.path(), state_dir, &id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> {
|
pub async fn recover_game_root(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
|
||||||
recover_download_transients(game_root).await?;
|
recover_download_transients(game_root).await?;
|
||||||
|
|
||||||
let intent = read_intent(game_root, id).await;
|
let intent = read_intent(state_dir, id).await;
|
||||||
let fs = inspect_install_fs(game_root).await;
|
let fs = inspect_install_fs(game_root).await;
|
||||||
match intent.state {
|
match intent.state {
|
||||||
InstallIntentState::None => recover_none_intent(game_root).await?,
|
InstallIntentState::None => recover_none_intent(game_root).await?,
|
||||||
InstallIntentState::Installing => recover_installing(game_root, id, intent, fs).await?,
|
InstallIntentState::Installing => {
|
||||||
InstallIntentState::Updating => recover_updating(game_root, id, intent, fs).await?,
|
recover_installing(game_root, state_dir, id, intent, fs).await?;
|
||||||
InstallIntentState::Uninstalling => recover_uninstalling(game_root, id, intent, fs).await?,
|
}
|
||||||
|
InstallIntentState::Updating => {
|
||||||
|
recover_updating(game_root, state_dir, id, intent, fs).await?;
|
||||||
|
}
|
||||||
|
InstallIntentState::Uninstalling => {
|
||||||
|
recover_uninstalling(game_root, state_dir, id, intent, fs).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -257,6 +280,7 @@ async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
|||||||
|
|
||||||
async fn recover_installing(
|
async fn recover_installing(
|
||||||
game_root: &Path,
|
game_root: &Path,
|
||||||
|
state_dir: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
intent: InstallIntent,
|
intent: InstallIntent,
|
||||||
fs: InstallFsState,
|
fs: InstallFsState,
|
||||||
@@ -268,11 +292,12 @@ async fn recover_installing(
|
|||||||
{
|
{
|
||||||
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
||||||
}
|
}
|
||||||
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recover_updating(
|
async fn recover_updating(
|
||||||
game_root: &Path,
|
game_root: &Path,
|
||||||
|
state_dir: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
intent: InstallIntent,
|
intent: InstallIntent,
|
||||||
fs: InstallFsState,
|
fs: InstallFsState,
|
||||||
@@ -301,11 +326,12 @@ async fn recover_updating(
|
|||||||
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
|
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recover_uninstalling(
|
async fn recover_uninstalling(
|
||||||
game_root: &Path,
|
game_root: &Path,
|
||||||
|
state_dir: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
intent: InstallIntent,
|
intent: InstallIntent,
|
||||||
fs: InstallFsState,
|
fs: InstallFsState,
|
||||||
@@ -323,7 +349,7 @@ async fn recover_uninstalling(
|
|||||||
} => uninstall_inner(game_root).await?,
|
} => uninstall_inner(game_root).await?,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
|
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
|
||||||
@@ -416,6 +442,10 @@ async fn restore_backup(game_root: &Path) -> eyre::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
|
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
|
||||||
|
if !path_exists(path).await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
match tokio::fs::remove_file(path).await {
|
match tokio::fs::remove_file(path).await {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
|
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
|
||||||
@@ -437,6 +467,10 @@ async fn path_is_dir(path: &Path) -> bool {
|
|||||||
.is_ok_and(|metadata| metadata.is_dir())
|
.is_ok_and(|metadata| metadata.is_dir())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn path_exists(path: &Path) -> bool {
|
||||||
|
tokio::fs::metadata(path).await.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
fn local_dir(game_root: &Path) -> PathBuf {
|
fn local_dir(game_root: &Path) -> PathBuf {
|
||||||
game_root.join(LOCAL_DIR)
|
game_root.join(LOCAL_DIR)
|
||||||
}
|
}
|
||||||
@@ -530,33 +564,39 @@ mod tests {
|
|||||||
Arc::new(FakeUnpacker::default())
|
Arc::new(FakeUnpacker::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_state() -> TempDir {
|
||||||
|
TempDir::new("lanspread-install-state")
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn install_success_promotes_staging_and_clears_intent() {
|
async fn install_success_promotes_staging_and_clears_intent() {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
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");
|
||||||
|
|
||||||
install(&root, "game", successful_unpacker())
|
install(&root, state.path(), "game", successful_unpacker())
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
|
|
||||||
assert!(root.join("local").join("payload.txt").is_file());
|
assert!(root.join("local").join("payload.txt").is_file());
|
||||||
assert!(!root.join(".local.installing").exists());
|
assert!(!root.join(".local.installing").exists());
|
||||||
let intent = read_intent(&root, "game").await;
|
let intent = read_intent(state.path(), "game").await;
|
||||||
assert_eq!(intent.state, InstallIntentState::None);
|
assert_eq!(intent.state, InstallIntentState::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
|
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
let state = test_state();
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join("b.eti"), b"archive");
|
write_file(&root.join("b.eti"), b"archive");
|
||||||
write_file(&root.join("a.eti"), b"archive");
|
write_file(&root.join("a.eti"), b"archive");
|
||||||
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, "game", unpacker.clone())
|
install(&root, state.path(), "game", unpacker.clone())
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
|
|
||||||
@@ -573,12 +613,18 @@ mod tests {
|
|||||||
#[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");
|
||||||
|
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(&root.join("local").join("old.txt"), b"old");
|
write_file(&root.join("local").join("old.txt"), b"old");
|
||||||
|
|
||||||
let err = update(&root, "game", Arc::new(FakeUnpacker::failing()))
|
let err = update(
|
||||||
|
&root,
|
||||||
|
state.path(),
|
||||||
|
"game",
|
||||||
|
Arc::new(FakeUnpacker::failing()),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("update should fail");
|
.expect_err("update should fail");
|
||||||
|
|
||||||
@@ -586,19 +632,25 @@ mod tests {
|
|||||||
assert!(root.join("local").join("old.txt").is_file());
|
assert!(root.join("local").join("old.txt").is_file());
|
||||||
assert!(!root.join(".local.installing").exists());
|
assert!(!root.join(".local.installing").exists());
|
||||||
assert!(!root.join(".local.backup").exists());
|
assert!(!root.join(".local.backup").exists());
|
||||||
let intent = read_intent(&root, "game").await;
|
let intent = read_intent(state.path(), "game").await;
|
||||||
assert_eq!(intent.state, InstallIntentState::None);
|
assert_eq!(intent.state, InstallIntentState::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_commit_rename_failure_restores_previous_local() {
|
async fn update_commit_rename_failure_restores_previous_local() {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
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(&root.join("local").join("old.txt"), b"old");
|
write_file(&root.join("local").join("old.txt"), b"old");
|
||||||
|
|
||||||
let err = update(&root, "game", Arc::new(FakeUnpacker::commit_conflict()))
|
let err = update(
|
||||||
|
&root,
|
||||||
|
state.path(),
|
||||||
|
"game",
|
||||||
|
Arc::new(FakeUnpacker::commit_conflict()),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("update should fail at commit rename");
|
.expect_err("update should fail at commit rename");
|
||||||
|
|
||||||
@@ -614,19 +666,20 @@ mod tests {
|
|||||||
assert!(!root.join("local").join("conflict.txt").exists());
|
assert!(!root.join("local").join("conflict.txt").exists());
|
||||||
assert!(!root.join(".local.installing").exists());
|
assert!(!root.join(".local.installing").exists());
|
||||||
assert!(!root.join(".local.backup").exists());
|
assert!(!root.join(".local.backup").exists());
|
||||||
let intent = read_intent(&root, "game").await;
|
let intent = read_intent(state.path(), "game").await;
|
||||||
assert_eq!(intent.state, InstallIntentState::None);
|
assert_eq!(intent.state, InstallIntentState::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_success_promotes_new_local_and_removes_backup() {
|
async fn update_success_promotes_new_local_and_removes_backup() {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
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(&root.join("local").join("old.txt"), b"old");
|
write_file(&root.join("local").join("old.txt"), b"old");
|
||||||
|
|
||||||
update(&root, "game", successful_unpacker())
|
update(&root, state.path(), "game", successful_unpacker())
|
||||||
.await
|
.await
|
||||||
.expect("update should succeed");
|
.expect("update should succeed");
|
||||||
|
|
||||||
@@ -634,19 +687,20 @@ mod tests {
|
|||||||
assert!(!root.join("local").join("old.txt").exists());
|
assert!(!root.join("local").join("old.txt").exists());
|
||||||
assert!(!root.join(".local.installing").exists());
|
assert!(!root.join(".local.installing").exists());
|
||||||
assert!(!root.join(".local.backup").exists());
|
assert!(!root.join(".local.backup").exists());
|
||||||
let intent = read_intent(&root, "game").await;
|
let intent = read_intent(state.path(), "game").await;
|
||||||
assert_eq!(intent.state, InstallIntentState::None);
|
assert_eq!(intent.state, InstallIntentState::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn uninstall_removes_only_local_install() {
|
async fn uninstall_removes_only_local_install() {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
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(&root.join("local").join("payload.txt"), b"installed");
|
write_file(&root.join("local").join("payload.txt"), b"installed");
|
||||||
|
|
||||||
uninstall(&root, "game")
|
uninstall(&root, state.path(), "game")
|
||||||
.await
|
.await
|
||||||
.expect("uninstall should succeed");
|
.expect("uninstall should succeed");
|
||||||
|
|
||||||
@@ -661,6 +715,7 @@ mod tests {
|
|||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
let state = test_state();
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
let locked_dir = root.join("local").join("locked");
|
let locked_dir = root.join("local").join("locked");
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
@@ -669,7 +724,7 @@ mod tests {
|
|||||||
std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o500))
|
std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o500))
|
||||||
.expect("locked dir permissions should be set");
|
.expect("locked dir permissions should be set");
|
||||||
|
|
||||||
let _err = uninstall(&root, "game")
|
let _err = uninstall(&root, state.path(), "game")
|
||||||
.await
|
.await
|
||||||
.expect_err("uninstall should fail while deleting backup");
|
.expect_err("uninstall should fail while deleting backup");
|
||||||
|
|
||||||
@@ -697,7 +752,7 @@ mod tests {
|
|||||||
b"locked"
|
b"locked"
|
||||||
);
|
);
|
||||||
assert!(!root.join(".local.backup").exists());
|
assert!(!root.join(".local.backup").exists());
|
||||||
let intent = read_intent(&root, "game").await;
|
let intent = read_intent(state.path(), "game").await;
|
||||||
assert_eq!(intent.state, InstallIntentState::None);
|
assert_eq!(intent.state, InstallIntentState::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,23 +899,25 @@ mod tests {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let state = test_state();
|
||||||
for case in cases {
|
for case in cases {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
seed_recovery_case(&root, &case);
|
seed_recovery_case(&root, &case);
|
||||||
write_intent(
|
write_intent(
|
||||||
&root,
|
state.path(),
|
||||||
|
"game",
|
||||||
&InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())),
|
&InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name));
|
.unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name));
|
||||||
|
|
||||||
recover_game_root(&root, "game")
|
recover_game_root(&root, state.path(), "game")
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));
|
.unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));
|
||||||
|
|
||||||
assert_recovered_case(&root, &case);
|
assert_recovered_case(&root, &case);
|
||||||
let intent = read_intent(&root, "game").await;
|
let intent = read_intent(state.path(), "game").await;
|
||||||
assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
|
assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
intent.eti_version.as_deref(),
|
intent.eti_version.as_deref(),
|
||||||
@@ -874,10 +931,11 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
|
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
let state = test_state();
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join(".local.backup").join("user.txt"), b"user");
|
write_file(&root.join(".local.backup").join("user.txt"), b"user");
|
||||||
|
|
||||||
recover_game_root(&root, "game")
|
recover_game_root(&root, state.path(), "game")
|
||||||
.await
|
.await
|
||||||
.expect("recovery should succeed");
|
.expect("recovery should succeed");
|
||||||
|
|
||||||
@@ -887,11 +945,12 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn download_recovery_sweeps_reserved_version_files() {
|
async fn download_recovery_sweeps_reserved_version_files() {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
let state = test_state();
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join(VERSION_TMP_FILE), b"tmp");
|
write_file(&root.join(VERSION_TMP_FILE), b"tmp");
|
||||||
write_file(&root.join(VERSION_DISCARDED_FILE), b"old");
|
write_file(&root.join(VERSION_DISCARDED_FILE), b"old");
|
||||||
|
|
||||||
recover_game_root(&root, "game")
|
recover_game_root(&root, state.path(), "game")
|
||||||
.await
|
.await
|
||||||
.expect("recovery should succeed");
|
.expect("recovery should succeed");
|
||||||
|
|
||||||
@@ -902,12 +961,17 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn startup_recovery_skips_active_game_roots() {
|
async fn startup_recovery_skips_active_game_roots() {
|
||||||
let temp = TempDir::new("lanspread-install");
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
let state = test_state();
|
||||||
let active_root = temp.path().join("active");
|
let active_root = temp.path().join("active");
|
||||||
let inactive_root = temp.path().join("inactive");
|
let inactive_root = temp.path().join("inactive");
|
||||||
write_file(&active_root.join(VERSION_TMP_FILE), b"tmp");
|
write_file(&active_root.join(VERSION_TMP_FILE), b"tmp");
|
||||||
write_file(&inactive_root.join(VERSION_TMP_FILE), b"tmp");
|
write_file(&inactive_root.join(VERSION_TMP_FILE), b"tmp");
|
||||||
|
|
||||||
recover_on_startup(temp.path(), &HashSet::from(["active".to_string()]))
|
recover_on_startup(
|
||||||
|
temp.path(),
|
||||||
|
state.path(),
|
||||||
|
&HashSet::from(["active".to_string()]),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("recovery should succeed");
|
.expect("recovery should succeed");
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ mod identity;
|
|||||||
mod install;
|
mod install;
|
||||||
mod library;
|
mod library;
|
||||||
mod local_games;
|
mod local_games;
|
||||||
|
mod migration;
|
||||||
mod network;
|
mod network;
|
||||||
mod path_validation;
|
mod path_validation;
|
||||||
mod peer;
|
mod peer;
|
||||||
@@ -29,6 +30,7 @@ mod peer_db;
|
|||||||
mod remote_peer;
|
mod remote_peer;
|
||||||
mod services;
|
mod services;
|
||||||
mod startup;
|
mod startup;
|
||||||
|
mod state_paths;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test_support;
|
mod test_support;
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
|
|||||||
pub use error::PeerError;
|
pub use error::PeerError;
|
||||||
pub use install::{UnpackFuture, Unpacker};
|
pub use install::{UnpackFuture, Unpacker};
|
||||||
use lanspread_db::db::{Game, GameFileDescription};
|
use lanspread_db::db::{Game, GameFileDescription};
|
||||||
|
pub use migration::{MigrationReport, migrate_legacy_state};
|
||||||
pub use peer_db::{
|
pub use peer_db::{
|
||||||
MajorityValidationResult,
|
MajorityValidationResult,
|
||||||
PeerGameDB,
|
PeerGameDB,
|
||||||
@@ -56,7 +59,6 @@ use tokio::sync::{
|
|||||||
};
|
};
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
|
|
||||||
pub use crate::startup::PeerRuntimeHandle;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
context::Ctx,
|
context::Ctx,
|
||||||
handlers::{
|
handlers::{
|
||||||
@@ -73,7 +75,9 @@ use crate::{
|
|||||||
handle_uninstall_game_command,
|
handle_uninstall_game_command,
|
||||||
load_local_library,
|
load_local_library,
|
||||||
},
|
},
|
||||||
|
state_paths::resolve_state_dir,
|
||||||
};
|
};
|
||||||
|
pub use crate::{startup::PeerRuntimeHandle, state_paths::setup_done_path};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Public API types
|
// Public API types
|
||||||
@@ -300,12 +304,13 @@ pub fn start_peer_with_options(
|
|||||||
options: PeerStartOptions,
|
options: PeerStartOptions,
|
||||||
) -> eyre::Result<PeerRuntimeHandle> {
|
) -> eyre::Result<PeerRuntimeHandle> {
|
||||||
let PeerStartOptions { state_dir } = options;
|
let PeerStartOptions { state_dir } = options;
|
||||||
|
let state_dir = resolve_state_dir(state_dir.as_deref());
|
||||||
let game_dir = game_dir.into();
|
let game_dir = game_dir.into();
|
||||||
log::info!(
|
log::info!(
|
||||||
"Starting peer system with game directory: {}",
|
"Starting peer system with game directory: {}",
|
||||||
game_dir.display()
|
game_dir.display()
|
||||||
);
|
);
|
||||||
let peer_id = identity::load_or_create_peer_id(state_dir.as_deref())?;
|
let peer_id = identity::load_or_create_peer_id(&state_dir)?;
|
||||||
|
|
||||||
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
|
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
|
||||||
@@ -316,6 +321,7 @@ pub fn start_peer_with_options(
|
|||||||
peer_game_db,
|
peer_game_db,
|
||||||
peer_id,
|
peer_id,
|
||||||
game_dir,
|
game_dir,
|
||||||
|
state_dir,
|
||||||
unpacker,
|
unpacker,
|
||||||
catalog,
|
catalog,
|
||||||
))
|
))
|
||||||
@@ -329,6 +335,7 @@ async fn run_peer(
|
|||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
peer_id: String,
|
peer_id: String,
|
||||||
game_dir: PathBuf,
|
game_dir: PathBuf,
|
||||||
|
state_dir: PathBuf,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
task_tracker: TaskTracker,
|
task_tracker: TaskTracker,
|
||||||
@@ -338,6 +345,7 @@ async fn run_peer(
|
|||||||
peer_game_db,
|
peer_game_db,
|
||||||
peer_id,
|
peer_id,
|
||||||
game_dir,
|
game_dir,
|
||||||
|
state_dir,
|
||||||
unpacker,
|
unpacker,
|
||||||
shutdown,
|
shutdown,
|
||||||
task_tracker,
|
task_tracker,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ pub async fn local_download_available(
|
|||||||
// Local library index and scanning
|
// Local library index and scanning
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const LIBRARY_INDEX_DIR: &str = ".lanspread";
|
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
|
||||||
const LIBRARY_INDEX_FILE: &str = "library_index.json";
|
const LIBRARY_INDEX_FILE: &str = "library_index.json";
|
||||||
const INTENT_LOG_FILE: &str = ".lanspread.json";
|
const INTENT_LOG_FILE: &str = ".lanspread.json";
|
||||||
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
||||||
@@ -114,8 +114,14 @@ pub struct LocalLibraryScan {
|
|||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn library_index_path(game_dir: &Path) -> PathBuf {
|
pub(crate) fn legacy_library_index_path(game_dir: &Path) -> PathBuf {
|
||||||
game_dir.join(LIBRARY_INDEX_DIR).join(LIBRARY_INDEX_FILE)
|
game_dir
|
||||||
|
.join(LEGACY_LIBRARY_INDEX_DIR)
|
||||||
|
.join(LIBRARY_INDEX_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn library_index_path(state_dir: &Path) -> PathBuf {
|
||||||
|
crate::state_paths::local_library_index_path(state_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn library_index_tmp_path(path: &Path) -> PathBuf {
|
fn library_index_tmp_path(path: &Path) -> PathBuf {
|
||||||
@@ -278,7 +284,7 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint>
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_ignored_game_root_name(name: &str) -> bool {
|
pub fn is_ignored_game_root_name(name: &str) -> bool {
|
||||||
name == LIBRARY_INDEX_DIR
|
name == LEGACY_LIBRARY_INDEX_DIR
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_reserved_transient_name(name: &str) -> bool {
|
fn is_reserved_transient_name(name: &str) -> bool {
|
||||||
@@ -286,7 +292,7 @@ fn is_reserved_transient_name(name: &str) -> bool {
|
|||||||
|| name == VERSION_TMP_FILE
|
|| name == VERSION_TMP_FILE
|
||||||
|| name == VERSION_DISCARDED_FILE
|
|| name == VERSION_DISCARDED_FILE
|
||||||
|| name == INTENT_LOG_FILE
|
|| name == INTENT_LOG_FILE
|
||||||
|| name == LIBRARY_INDEX_DIR
|
|| name == LEGACY_LIBRARY_INDEX_DIR
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
|
fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
|
||||||
@@ -550,9 +556,11 @@ fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
|
|||||||
/// Scans the local game directory and returns summaries plus a game database.
|
/// Scans the local game directory and returns summaries plus a game database.
|
||||||
pub async fn scan_local_library(
|
pub async fn scan_local_library(
|
||||||
game_dir: impl AsRef<Path>,
|
game_dir: impl AsRef<Path>,
|
||||||
|
state_dir: impl AsRef<Path>,
|
||||||
catalog: &HashSet<String>,
|
catalog: &HashSet<String>,
|
||||||
) -> eyre::Result<LocalLibraryScan> {
|
) -> eyre::Result<LocalLibraryScan> {
|
||||||
let game_path = game_dir.as_ref();
|
let game_path = game_dir.as_ref();
|
||||||
|
let state_path = state_dir.as_ref();
|
||||||
|
|
||||||
let metadata = match tokio::fs::metadata(game_path).await {
|
let metadata = match tokio::fs::metadata(game_path).await {
|
||||||
Ok(metadata) => metadata,
|
Ok(metadata) => metadata,
|
||||||
@@ -577,7 +585,7 @@ pub async fn scan_local_library(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
||||||
let index_path = library_index_path(game_path);
|
let index_path = library_index_path(state_path);
|
||||||
let mut index = load_library_index(&index_path).await;
|
let mut index = load_library_index(&index_path).await;
|
||||||
let mut seen_ids = HashSet::new();
|
let mut seen_ids = HashSet::new();
|
||||||
let mut summaries = HashMap::new();
|
let mut summaries = HashMap::new();
|
||||||
@@ -636,12 +644,14 @@ pub async fn scan_local_library(
|
|||||||
/// Rescans a single game root through the cached index and returns full library state.
|
/// Rescans a single game root through the cached index and returns full library state.
|
||||||
pub async fn rescan_local_game(
|
pub async fn rescan_local_game(
|
||||||
game_dir: impl AsRef<Path>,
|
game_dir: impl AsRef<Path>,
|
||||||
|
state_dir: impl AsRef<Path>,
|
||||||
catalog: &HashSet<String>,
|
catalog: &HashSet<String>,
|
||||||
game_id: &str,
|
game_id: &str,
|
||||||
) -> eyre::Result<LocalLibraryScan> {
|
) -> eyre::Result<LocalLibraryScan> {
|
||||||
let game_path = game_dir.as_ref();
|
let game_path = game_dir.as_ref();
|
||||||
|
let state_path = state_dir.as_ref();
|
||||||
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
||||||
let index_path = library_index_path(game_path);
|
let index_path = library_index_path(state_path);
|
||||||
let mut index = load_library_index(&index_path).await;
|
let mut index = load_library_index(&index_path).await;
|
||||||
|
|
||||||
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
|
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
|
||||||
@@ -765,6 +775,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
|
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
|
||||||
let temp = TempDir::new("lanspread-local-games");
|
let temp = TempDir::new("lanspread-local-games");
|
||||||
|
let state = TempDir::new("lanspread-local-games-state");
|
||||||
let catalog = HashSet::from([
|
let catalog = HashSet::from([
|
||||||
"ready".to_string(),
|
"ready".to_string(),
|
||||||
"local-only".to_string(),
|
"local-only".to_string(),
|
||||||
@@ -783,7 +794,7 @@ mod tests {
|
|||||||
b"20250101",
|
b"20250101",
|
||||||
);
|
);
|
||||||
|
|
||||||
let scan = scan_local_library(temp.path(), &catalog)
|
let scan = scan_local_library(temp.path(), state.path(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("scan should succeed");
|
.expect("scan should succeed");
|
||||||
|
|
||||||
@@ -818,11 +829,12 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
|
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
|
||||||
let temp = TempDir::new("lanspread-local-games");
|
let temp = TempDir::new("lanspread-local-games");
|
||||||
|
let state = TempDir::new("lanspread-local-games-state");
|
||||||
let catalog = HashSet::from(["game".to_string()]);
|
let catalog = HashSet::from(["game".to_string()]);
|
||||||
std::fs::create_dir_all(temp.path().join("game").join("local"))
|
std::fs::create_dir_all(temp.path().join("game").join("local"))
|
||||||
.expect("local install dir should be created");
|
.expect("local install dir should be created");
|
||||||
|
|
||||||
let first_scan = scan_local_library(temp.path(), &catalog)
|
let first_scan = scan_local_library(temp.path(), state.path(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("initial scan should succeed");
|
.expect("initial scan should succeed");
|
||||||
let local_only = first_scan
|
let local_only = first_scan
|
||||||
@@ -835,7 +847,7 @@ mod tests {
|
|||||||
|
|
||||||
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
||||||
|
|
||||||
let rescan = rescan_local_game(temp.path(), &catalog, "game")
|
let rescan = rescan_local_game(temp.path(), state.path(), &catalog, "game")
|
||||||
.await
|
.await
|
||||||
.expect("rescan should succeed");
|
.expect("rescan should succeed");
|
||||||
let ready = rescan
|
let ready = rescan
|
||||||
@@ -851,11 +863,12 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn concurrent_rescans_preserve_both_index_updates() {
|
async fn concurrent_rescans_preserve_both_index_updates() {
|
||||||
let temp = TempDir::new("lanspread-local-games-concurrent");
|
let temp = TempDir::new("lanspread-local-games-concurrent");
|
||||||
|
let state = TempDir::new("lanspread-local-games-state");
|
||||||
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
|
let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]);
|
||||||
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
|
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
|
||||||
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
|
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
|
||||||
|
|
||||||
let initial = scan_local_library(temp.path(), &catalog)
|
let initial = scan_local_library(temp.path(), state.path(), &catalog)
|
||||||
.await
|
.await
|
||||||
.expect("initial scan should succeed");
|
.expect("initial scan should succeed");
|
||||||
assert_eq!(initial.revision, 1);
|
assert_eq!(initial.revision, 1);
|
||||||
@@ -864,13 +877,13 @@ mod tests {
|
|||||||
write_file(&temp.path().join("game-b").join("game-b.eti"), b"archive-b");
|
write_file(&temp.path().join("game-b").join("game-b.eti"), b"archive-b");
|
||||||
|
|
||||||
let (scan_a, scan_b) = tokio::join!(
|
let (scan_a, scan_b) = tokio::join!(
|
||||||
rescan_local_game(temp.path(), &catalog, "game-a"),
|
rescan_local_game(temp.path(), state.path(), &catalog, "game-a"),
|
||||||
rescan_local_game(temp.path(), &catalog, "game-b")
|
rescan_local_game(temp.path(), state.path(), &catalog, "game-b")
|
||||||
);
|
);
|
||||||
scan_a.expect("game-a rescan should succeed");
|
scan_a.expect("game-a rescan should succeed");
|
||||||
scan_b.expect("game-b rescan should succeed");
|
scan_b.expect("game-b rescan should succeed");
|
||||||
|
|
||||||
let index = load_library_index(&library_index_path(temp.path())).await;
|
let index = load_library_index(&library_index_path(state.path())).await;
|
||||||
assert_eq!(index.revision, 3);
|
assert_eq!(index.revision, 3);
|
||||||
let game_a = index
|
let game_a = index
|
||||||
.games
|
.games
|
||||||
|
|||||||
@@ -0,0 +1,612 @@
|
|||||||
|
use std::{
|
||||||
|
io::ErrorKind,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
use futures::{StreamExt as _, stream};
|
||||||
|
use tokio::io::AsyncWriteExt as _;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
install::intent::{
|
||||||
|
InstallIntent,
|
||||||
|
LEGACY_INTENT_FILE,
|
||||||
|
LEGACY_INTENT_TMP_FILE,
|
||||||
|
intent_path,
|
||||||
|
write_intent,
|
||||||
|
},
|
||||||
|
local_games::{is_ignored_game_root_name, legacy_library_index_path},
|
||||||
|
state_paths::{local_library_index_path, setup_done_path},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
|
||||||
|
const LEGACY_FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
|
||||||
|
const LEGACY_SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
|
||||||
|
const MIGRATION_CONCURRENCY: usize = 16;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
|
||||||
|
pub struct MigrationReport {
|
||||||
|
pub games_checked: usize,
|
||||||
|
pub library_index_migrated: bool,
|
||||||
|
pub install_intents_migrated: usize,
|
||||||
|
pub setup_markers_migrated: usize,
|
||||||
|
pub legacy_files_deleted: usize,
|
||||||
|
pub unknown_softlan_files: usize,
|
||||||
|
pub failures: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MigrationReport {
|
||||||
|
fn merge(&mut self, other: Self) {
|
||||||
|
self.games_checked += other.games_checked;
|
||||||
|
self.library_index_migrated |= other.library_index_migrated;
|
||||||
|
self.install_intents_migrated += other.install_intents_migrated;
|
||||||
|
self.setup_markers_migrated += other.setup_markers_migrated;
|
||||||
|
self.legacy_files_deleted += other.legacy_files_deleted;
|
||||||
|
self.unknown_softlan_files += other.unknown_softlan_files;
|
||||||
|
self.failures += other.failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrates legacy app-owned files out of the configured game directory.
|
||||||
|
///
|
||||||
|
/// This is intentionally separate from normal operation: callers should run it
|
||||||
|
/// before starting the peer runtime for a game directory.
|
||||||
|
pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> MigrationReport {
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut report = MigrationReport::default();
|
||||||
|
|
||||||
|
report.merge(migrate_library_index(game_dir, state_dir).await);
|
||||||
|
|
||||||
|
let game_roots = match collect_game_roots(game_dir).await {
|
||||||
|
Ok(game_roots) => game_roots,
|
||||||
|
Err(err) => {
|
||||||
|
if err.kind() != ErrorKind::NotFound {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to enumerate game roots for legacy state migration in {}: {err}",
|
||||||
|
game_dir.display()
|
||||||
|
);
|
||||||
|
report.failures += 1;
|
||||||
|
}
|
||||||
|
log_migration_report(&report, started);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let game_reports = stream::iter(game_roots)
|
||||||
|
.map(|(id, root)| async move { migrate_game_root(state_dir, id, root).await })
|
||||||
|
.buffer_unordered(MIGRATION_CONCURRENCY)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for game_report in game_reports {
|
||||||
|
report.merge(game_report);
|
||||||
|
}
|
||||||
|
|
||||||
|
log_migration_report(&report, started);
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
let mut entries = tokio::fs::read_dir(game_dir).await?;
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
if !entry.file_type().await?.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_ignored_game_root_name(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
roots.push((id, entry.path()));
|
||||||
|
}
|
||||||
|
Ok(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport {
|
||||||
|
let mut report = MigrationReport::default();
|
||||||
|
let legacy_path = legacy_library_index_path(game_dir);
|
||||||
|
let target_path = local_library_index_path(state_dir);
|
||||||
|
|
||||||
|
match migrate_raw_file(&legacy_path, &target_path).await {
|
||||||
|
Ok(MigrationOutcome::Migrated) => {
|
||||||
|
report.library_index_migrated = true;
|
||||||
|
report.legacy_files_deleted += 1;
|
||||||
|
}
|
||||||
|
Ok(MigrationOutcome::TargetAlreadyExists) => {
|
||||||
|
report.legacy_files_deleted += 1;
|
||||||
|
}
|
||||||
|
Ok(MigrationOutcome::SourceMissing) => {}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to migrate legacy library index {} to {}: {err}",
|
||||||
|
legacy_path.display(),
|
||||||
|
target_path.display()
|
||||||
|
);
|
||||||
|
report.failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path)).await);
|
||||||
|
report.merge(remove_empty_legacy_library_dir(game_dir).await);
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate_game_root(state_dir: &Path, id: String, root: PathBuf) -> MigrationReport {
|
||||||
|
let mut report = MigrationReport {
|
||||||
|
games_checked: 1,
|
||||||
|
..MigrationReport::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
report.merge(migrate_install_intent(state_dir, &id, &root).await);
|
||||||
|
report.merge(delete_if_exists(&root.join(LEGACY_INTENT_TMP_FILE)).await);
|
||||||
|
report.merge(migrate_setup_marker(state_dir, &id, &root).await);
|
||||||
|
report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)).await);
|
||||||
|
report.merge(note_unknown_softlan_files(&root).await);
|
||||||
|
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate_install_intent(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
|
||||||
|
let mut report = MigrationReport::default();
|
||||||
|
let legacy_path = root.join(LEGACY_INTENT_FILE);
|
||||||
|
let target_path = intent_path(state_dir, id);
|
||||||
|
|
||||||
|
match path_exists(&legacy_path).await {
|
||||||
|
Ok(false) => return report,
|
||||||
|
Ok(true) => {}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to inspect legacy install intent {}: {err}",
|
||||||
|
legacy_path.display()
|
||||||
|
);
|
||||||
|
report.failures += 1;
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match path_exists(&target_path).await {
|
||||||
|
Ok(true) => {
|
||||||
|
report.merge(delete_file(&legacy_path).await);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to inspect app-state install intent {}: {err}",
|
||||||
|
target_path.display()
|
||||||
|
);
|
||||||
|
report.failures += 1;
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = match tokio::fs::read_to_string(&legacy_path).await {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to read legacy install intent {}: {err}",
|
||||||
|
legacy_path.display()
|
||||||
|
);
|
||||||
|
report.failures += 1;
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let intent = match serde_json::from_str::<InstallIntent>(&data) {
|
||||||
|
Ok(intent) if intent.is_current_for(id) => intent,
|
||||||
|
Ok(intent) => {
|
||||||
|
log::warn!(
|
||||||
|
"Leaving legacy install intent {} in place because it belongs to id {} schema {}",
|
||||||
|
legacy_path.display(),
|
||||||
|
intent.id,
|
||||||
|
intent.schema_version
|
||||||
|
);
|
||||||
|
report.failures += 1;
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Leaving corrupt legacy install intent {} in place: {err}",
|
||||||
|
legacy_path.display()
|
||||||
|
);
|
||||||
|
report.failures += 1;
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = write_intent(state_dir, id, &intent).await {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to write migrated install intent {}: {err}",
|
||||||
|
target_path.display()
|
||||||
|
);
|
||||||
|
report.failures += 1;
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
report.install_intents_migrated += 1;
|
||||||
|
report.merge(delete_file(&legacy_path).await);
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
|
||||||
|
let mut report = MigrationReport::default();
|
||||||
|
let legacy_path = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
|
||||||
|
let target_path = setup_done_path(state_dir, id);
|
||||||
|
|
||||||
|
match migrate_empty_marker(&legacy_path, &target_path).await {
|
||||||
|
Ok(MigrationOutcome::Migrated) => {
|
||||||
|
report.setup_markers_migrated += 1;
|
||||||
|
report.legacy_files_deleted += 1;
|
||||||
|
}
|
||||||
|
Ok(MigrationOutcome::TargetAlreadyExists) => {
|
||||||
|
report.legacy_files_deleted += 1;
|
||||||
|
}
|
||||||
|
Ok(MigrationOutcome::SourceMissing) => {}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to migrate legacy setup marker {} to {}: {err}",
|
||||||
|
legacy_path.display(),
|
||||||
|
target_path.display()
|
||||||
|
);
|
||||||
|
report.failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn note_unknown_softlan_files(root: &Path) -> MigrationReport {
|
||||||
|
let mut report = MigrationReport::default();
|
||||||
|
report.unknown_softlan_files += count_unknown_softlan_files(root).await;
|
||||||
|
report.unknown_softlan_files += count_unknown_softlan_files(&root.join("local")).await;
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_unknown_softlan_files(dir: &Path) -> usize {
|
||||||
|
let mut count = 0;
|
||||||
|
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(err) if err.kind() == ErrorKind::NotFound => return 0,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to inspect {} for legacy .softlan files: {err}",
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !name.starts_with(".softlan_")
|
||||||
|
|| name == LEGACY_SOFTLAN_INSTALL_MARKER
|
||||||
|
|| name == LEGACY_FIRST_START_DONE_FILE
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
log::info!(
|
||||||
|
"Leaving unknown legacy .softlan file in place: {}",
|
||||||
|
entry.path().display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum MigrationOutcome {
|
||||||
|
SourceMissing,
|
||||||
|
TargetAlreadyExists,
|
||||||
|
Migrated,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate_raw_file(
|
||||||
|
legacy_path: &Path,
|
||||||
|
target_path: &Path,
|
||||||
|
) -> std::io::Result<MigrationOutcome> {
|
||||||
|
if !path_exists(legacy_path).await? {
|
||||||
|
return Ok(MigrationOutcome::SourceMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if path_exists(target_path).await? {
|
||||||
|
remove_file_if_exists(legacy_path).await?;
|
||||||
|
return Ok(MigrationOutcome::TargetAlreadyExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = tokio::fs::read(legacy_path).await?;
|
||||||
|
write_bytes_atomically(target_path, &data).await?;
|
||||||
|
remove_file_if_exists(legacy_path).await?;
|
||||||
|
Ok(MigrationOutcome::Migrated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate_empty_marker(
|
||||||
|
legacy_path: &Path,
|
||||||
|
target_path: &Path,
|
||||||
|
) -> std::io::Result<MigrationOutcome> {
|
||||||
|
if !path_exists(legacy_path).await? {
|
||||||
|
return Ok(MigrationOutcome::SourceMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if path_exists(target_path).await? {
|
||||||
|
remove_file_if_exists(legacy_path).await?;
|
||||||
|
return Ok(MigrationOutcome::TargetAlreadyExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = target_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
tokio::fs::File::create(target_path)
|
||||||
|
.await?
|
||||||
|
.sync_all()
|
||||||
|
.await?;
|
||||||
|
remove_file_if_exists(legacy_path).await?;
|
||||||
|
Ok(MigrationOutcome::Migrated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp_path = library_index_tmp_path(path);
|
||||||
|
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||||
|
file.write_all(data).await?;
|
||||||
|
file.sync_all().await?;
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
tokio::fs::rename(&tmp_path, path).await?;
|
||||||
|
sync_parent_dir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn library_index_tmp_path(path: &Path) -> PathBuf {
|
||||||
|
let Some(file_name) = path.file_name() else {
|
||||||
|
return path.with_extension("tmp");
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tmp_name = file_name.to_os_string();
|
||||||
|
tmp_name.push(".tmp");
|
||||||
|
path.with_file_name(tmp_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn path_exists(path: &Path) -> std::io::Result<bool> {
|
||||||
|
match tokio::fs::metadata(path).await {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_if_exists(path: &Path) -> MigrationReport {
|
||||||
|
match remove_file_if_exists(path).await {
|
||||||
|
Ok(true) => MigrationReport {
|
||||||
|
legacy_files_deleted: 1,
|
||||||
|
..MigrationReport::default()
|
||||||
|
},
|
||||||
|
Ok(false) => MigrationReport::default(),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Failed to delete legacy file {}: {err}", path.display());
|
||||||
|
MigrationReport {
|
||||||
|
failures: 1,
|
||||||
|
..MigrationReport::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(path: &Path) -> MigrationReport {
|
||||||
|
match remove_file_if_exists(path).await {
|
||||||
|
Ok(true) => MigrationReport {
|
||||||
|
legacy_files_deleted: 1,
|
||||||
|
..MigrationReport::default()
|
||||||
|
},
|
||||||
|
Ok(false) => MigrationReport::default(),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Failed to delete legacy file {}: {err}", path.display());
|
||||||
|
MigrationReport {
|
||||||
|
failures: 1,
|
||||||
|
..MigrationReport::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_file_if_exists(path: &Path) -> std::io::Result<bool> {
|
||||||
|
if !path_exists(path).await? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::fs::remove_file(path).await {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport {
|
||||||
|
let path = game_dir.join(LEGACY_LIBRARY_INDEX_DIR);
|
||||||
|
let exists = match path_exists(&path).await {
|
||||||
|
Ok(exists) => exists,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to inspect legacy library index directory {}: {err}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
return MigrationReport {
|
||||||
|
failures: 1,
|
||||||
|
..MigrationReport::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !exists {
|
||||||
|
return MigrationReport::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::fs::remove_dir(&path).await {
|
||||||
|
Ok(()) => MigrationReport {
|
||||||
|
legacy_files_deleted: 1,
|
||||||
|
..MigrationReport::default()
|
||||||
|
},
|
||||||
|
Err(err)
|
||||||
|
if err.kind() == ErrorKind::NotFound || err.kind() == ErrorKind::DirectoryNotEmpty =>
|
||||||
|
{
|
||||||
|
MigrationReport::default()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to remove empty legacy library index directory {}: {err}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
MigrationReport {
|
||||||
|
failures: 1,
|
||||||
|
..MigrationReport::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_migration_report(report: &MigrationReport, started: Instant) {
|
||||||
|
log::info!(
|
||||||
|
"Legacy state migration finished in {:?}: games_checked={}, library_index_migrated={}, \
|
||||||
|
install_intents_migrated={}, setup_markers_migrated={}, legacy_files_deleted={}, \
|
||||||
|
unknown_softlan_files={}, failures={}",
|
||||||
|
started.elapsed(),
|
||||||
|
report.games_checked,
|
||||||
|
report.library_index_migrated,
|
||||||
|
report.install_intents_migrated,
|
||||||
|
report.setup_markers_migrated,
|
||||||
|
report.legacy_files_deleted,
|
||||||
|
report.unknown_softlan_files,
|
||||||
|
report.failures
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::File::open(parent)?.sync_all()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
install::intent::{InstallIntentState, read_intent},
|
||||||
|
test_support::TempDir,
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn migrates_legacy_library_index_to_app_state() {
|
||||||
|
let games = TempDir::new("lanspread-migration-games");
|
||||||
|
let state = TempDir::new("lanspread-migration-state");
|
||||||
|
let legacy_path = legacy_library_index_path(games.path());
|
||||||
|
let target_path = local_library_index_path(state.path());
|
||||||
|
let legacy_tmp_path = library_index_tmp_path(&legacy_path);
|
||||||
|
|
||||||
|
write_file(&legacy_path, br#"{"revision":7,"games":{}}"#);
|
||||||
|
write_file(&legacy_tmp_path, b"tmp");
|
||||||
|
|
||||||
|
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||||
|
|
||||||
|
assert!(report.library_index_migrated);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&target_path).expect("index should migrate"),
|
||||||
|
r#"{"revision":7,"games":{}}"#
|
||||||
|
);
|
||||||
|
assert!(!legacy_path.exists());
|
||||||
|
assert!(!legacy_tmp_path.exists());
|
||||||
|
assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn migrates_per_game_intent_and_setup_marker() {
|
||||||
|
let games = TempDir::new("lanspread-migration-games");
|
||||||
|
let state = TempDir::new("lanspread-migration-state");
|
||||||
|
let root = games.path().join("game");
|
||||||
|
let intent = InstallIntent::new(
|
||||||
|
"game",
|
||||||
|
InstallIntentState::Updating,
|
||||||
|
Some("20250101".to_string()),
|
||||||
|
);
|
||||||
|
let legacy_intent = root.join(LEGACY_INTENT_FILE);
|
||||||
|
let legacy_tmp = root.join(LEGACY_INTENT_TMP_FILE);
|
||||||
|
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
|
||||||
|
let legacy_marker = root.join(LEGACY_SOFTLAN_INSTALL_MARKER);
|
||||||
|
|
||||||
|
write_file(
|
||||||
|
&legacy_intent,
|
||||||
|
&serde_json::to_vec_pretty(&intent).expect("intent should serialize"),
|
||||||
|
);
|
||||||
|
write_file(&legacy_tmp, b"tmp");
|
||||||
|
write_file(&legacy_setup, b"");
|
||||||
|
write_file(&legacy_marker, b"");
|
||||||
|
|
||||||
|
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||||
|
|
||||||
|
assert_eq!(report.install_intents_migrated, 1);
|
||||||
|
assert_eq!(report.setup_markers_migrated, 1);
|
||||||
|
let migrated_intent = read_intent(state.path(), "game").await;
|
||||||
|
assert_eq!(migrated_intent.state, InstallIntentState::Updating);
|
||||||
|
assert_eq!(migrated_intent.eti_version.as_deref(), Some("20250101"));
|
||||||
|
assert!(setup_done_path(state.path(), "game").is_file());
|
||||||
|
assert!(!legacy_intent.exists());
|
||||||
|
assert!(!legacy_tmp.exists());
|
||||||
|
assert!(!legacy_setup.exists());
|
||||||
|
assert!(!legacy_marker.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn app_state_wins_over_legacy_per_game_state() {
|
||||||
|
let games = TempDir::new("lanspread-migration-games");
|
||||||
|
let state = TempDir::new("lanspread-migration-state");
|
||||||
|
let root = games.path().join("game");
|
||||||
|
let app_intent = InstallIntent::none("game", Some("app".to_string()));
|
||||||
|
let legacy_intent = InstallIntent::new(
|
||||||
|
"game",
|
||||||
|
InstallIntentState::Installing,
|
||||||
|
Some("legacy".to_string()),
|
||||||
|
);
|
||||||
|
let legacy_intent_path = root.join(LEGACY_INTENT_FILE);
|
||||||
|
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
|
||||||
|
|
||||||
|
write_intent(state.path(), "game", &app_intent)
|
||||||
|
.await
|
||||||
|
.expect("app-state intent should be written");
|
||||||
|
write_file(
|
||||||
|
&legacy_intent_path,
|
||||||
|
&serde_json::to_vec_pretty(&legacy_intent).expect("intent should serialize"),
|
||||||
|
);
|
||||||
|
write_file(&setup_done_path(state.path(), "game"), b"");
|
||||||
|
write_file(&legacy_setup, b"");
|
||||||
|
|
||||||
|
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||||
|
|
||||||
|
assert_eq!(report.install_intents_migrated, 0);
|
||||||
|
assert_eq!(report.setup_markers_migrated, 0);
|
||||||
|
let intent = read_intent(state.path(), "game").await;
|
||||||
|
assert_eq!(intent.state, InstallIntentState::None);
|
||||||
|
assert_eq!(intent.eti_version.as_deref(), Some("app"));
|
||||||
|
assert!(!legacy_intent_path.exists());
|
||||||
|
assert!(!legacy_setup.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -305,6 +305,7 @@ mod tests {
|
|||||||
peer_game_db.clone(),
|
peer_game_db.clone(),
|
||||||
"local-peer".to_string(),
|
"local-peer".to_string(),
|
||||||
PathBuf::new(),
|
PathBuf::new(),
|
||||||
|
PathBuf::new(),
|
||||||
Arc::new(NoopUnpacker),
|
Arc::new(NoopUnpacker),
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
TaskTracker::new(),
|
TaskTracker::new(),
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ async fn run_gated_rescan(
|
|||||||
|
|
||||||
let game_dir = ctx.game_dir.read().await.clone();
|
let game_dir = ctx.game_dir.read().await.clone();
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
match rescan_local_game(&game_dir, &catalog, &id).await {
|
match rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, &id).await {
|
||||||
Ok(scan) => update_and_announce_games(&ctx, &tx_notify_ui, scan).await,
|
Ok(scan) => update_and_announce_games(&ctx, &tx_notify_ui, scan).await,
|
||||||
Err(err) => log::error!("Failed to rescan local game {id}: {err}"),
|
Err(err) => log::error!("Failed to rescan local game {id}: {err}"),
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@ async fn run_gated_rescan(
|
|||||||
async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
||||||
let game_dir = ctx.game_dir.read().await.clone();
|
let game_dir = ctx.game_dir.read().await.clone();
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
match scan_local_library(&game_dir, &catalog).await {
|
match scan_local_library(&game_dir, ctx.state_dir.as_ref(), &catalog).await {
|
||||||
Ok(scan) => update_and_announce_games(ctx, tx_notify_ui, scan).await,
|
Ok(scan) => update_and_announce_games(ctx, tx_notify_ui, scan).await,
|
||||||
Err(err) => log::error!("Failed to scan local games directory: {err}"),
|
Err(err) => log::error!("Failed to scan local games directory: {err}"),
|
||||||
}
|
}
|
||||||
@@ -377,7 +377,8 @@ mod tests {
|
|||||||
Ctx::new(
|
Ctx::new(
|
||||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||||
"peer".to_string(),
|
"peer".to_string(),
|
||||||
game_dir,
|
game_dir.clone(),
|
||||||
|
game_dir.join(".test-state"),
|
||||||
Arc::new(NoopUnpacker),
|
Arc::new(NoopUnpacker),
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
TaskTracker::new(),
|
TaskTracker::new(),
|
||||||
|
|||||||
@@ -332,7 +332,8 @@ mod tests {
|
|||||||
Ctx::new(
|
Ctx::new(
|
||||||
Arc::new(RwLock::new(PeerGameDB::new())),
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
||||||
"peer".to_string(),
|
"peer".to_string(),
|
||||||
game_dir,
|
game_dir.clone(),
|
||||||
|
game_dir.join(".test-state"),
|
||||||
Arc::new(NoopUnpacker),
|
Arc::new(NoopUnpacker),
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
TaskTracker::new(),
|
TaskTracker::new(),
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ pub(crate) fn spawn_peer_runtime(
|
|||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
peer_id: String,
|
peer_id: String,
|
||||||
game_dir: PathBuf,
|
game_dir: PathBuf,
|
||||||
|
state_dir: PathBuf,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
catalog: Arc<RwLock<std::collections::HashSet<String>>>,
|
catalog: Arc<RwLock<std::collections::HashSet<String>>>,
|
||||||
) -> PeerRuntimeHandle {
|
) -> PeerRuntimeHandle {
|
||||||
@@ -98,6 +99,7 @@ pub(crate) fn spawn_peer_runtime(
|
|||||||
peer_game_db,
|
peer_game_db,
|
||||||
peer_id,
|
peer_id,
|
||||||
game_dir,
|
game_dir,
|
||||||
|
state_dir,
|
||||||
unpacker,
|
unpacker,
|
||||||
runtime_shutdown.clone(),
|
runtime_shutdown.clone(),
|
||||||
runtime_tracker.clone(),
|
runtime_tracker.clone(),
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const PEER_ID_FILE: &str = "peer_id";
|
||||||
|
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";
|
||||||
|
|
||||||
|
pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf {
|
||||||
|
if let Some(dir) = explicit {
|
||||||
|
return dir.to_path_buf();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") {
|
||||||
|
return PathBuf::from(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
|
||||||
|
return PathBuf::from(home).join(".lanspread");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::env::temp_dir().join("lanspread")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn peer_id_path(state_dir: &Path) -> PathBuf {
|
||||||
|
state_dir.join(PEER_ID_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn local_library_index_path(state_dir: &Path) -> PathBuf {
|
||||||
|
state_dir
|
||||||
|
.join(LOCAL_LIBRARY_DIR)
|
||||||
|
.join(LOCAL_LIBRARY_INDEX_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn game_state_dir(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||||
|
state_dir.join(GAMES_DIR).join(game_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn setup_done_path(state_dir: &Path, game_id: &str) -> PathBuf {
|
||||||
|
game_state_dir(state_dir, game_id).join(SETUP_DONE_FILE)
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
#[cfg(target_os = "windows")]
|
|
||||||
use std::fs::File;
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
path::{Component, Path, PathBuf},
|
path::{Component, Path, PathBuf},
|
||||||
sync::Arc,
|
sync::{Arc, OnceLock},
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,9 +16,11 @@ use lanspread_peer::{
|
|||||||
PeerEvent,
|
PeerEvent,
|
||||||
PeerGameDB,
|
PeerGameDB,
|
||||||
PeerRuntimeHandle,
|
PeerRuntimeHandle,
|
||||||
|
PeerStartOptions,
|
||||||
UnpackFuture,
|
UnpackFuture,
|
||||||
Unpacker,
|
Unpacker,
|
||||||
start_peer,
|
migrate_legacy_state,
|
||||||
|
start_peer_with_options,
|
||||||
};
|
};
|
||||||
use tauri::{AppHandle, Emitter as _, Manager};
|
use tauri::{AppHandle, Emitter as _, Manager};
|
||||||
use tauri_plugin_shell::{ShellExt, process::Command};
|
use tauri_plugin_shell::{ShellExt, process::Command};
|
||||||
@@ -42,6 +42,7 @@ 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>>>,
|
||||||
|
state_dir: OnceLock<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
||||||
@@ -109,8 +110,6 @@ async fn get_unpack_logs(
|
|||||||
Ok(state.inner().unpack_logs.read().await.clone())
|
Ok(state.inner().unpack_logs.read().await.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
const GAME_SETUP_SCRIPT: &str = "game_setup.cmd";
|
const GAME_SETUP_SCRIPT: &str = "game_setup.cmd";
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
@@ -441,8 +440,23 @@ fn sanitize_username(username: &str) -> String {
|
|||||||
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
||||||
|
script_params_with_mode("/c", script_path, id, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
fn server_script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
||||||
|
script_params_with_mode("/k", script_path, id, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
fn script_params_with_mode(
|
||||||
|
cmd_mode: &str,
|
||||||
|
script_path: &Path,
|
||||||
|
id: &str,
|
||||||
|
settings: &LaunchSettings,
|
||||||
|
) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"/d /s /c ""{}" "local" "{}" "{}" "{}"""#,
|
r#"/d /s {cmd_mode} ""{}" "local" "{}" "{}" "{}"""#,
|
||||||
script_path.display(),
|
script_path.display(),
|
||||||
id,
|
id,
|
||||||
settings.language,
|
settings.language,
|
||||||
@@ -487,7 +501,12 @@ async fn get_game_thumbnail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
|
fn run_as_admin(
|
||||||
|
file: &str,
|
||||||
|
params: &str,
|
||||||
|
dir: &str,
|
||||||
|
show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD,
|
||||||
|
) -> bool {
|
||||||
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
||||||
|
|
||||||
use windows::{Win32::UI::Shell::ShellExecuteW, core::PCWSTR};
|
use windows::{Win32::UI::Shell::ShellExecuteW, core::PCWSTR};
|
||||||
@@ -504,7 +523,7 @@ fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
|
|||||||
PCWSTR::from_raw(file_wide.as_ptr()),
|
PCWSTR::from_raw(file_wide.as_ptr()),
|
||||||
PCWSTR::from_raw(params_wide.as_ptr()),
|
PCWSTR::from_raw(params_wide.as_ptr()),
|
||||||
PCWSTR::from_raw(dir_wide.as_ptr()),
|
PCWSTR::from_raw(dir_wide.as_ptr()),
|
||||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
show_cmd,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -540,9 +559,13 @@ async fn run_game_windows(
|
|||||||
|
|
||||||
let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT);
|
let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT);
|
||||||
let game_start_bin = game_path.join(GAME_START_SCRIPT);
|
let game_start_bin = game_path.join(GAME_START_SCRIPT);
|
||||||
|
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
|
||||||
|
log::error!("app state directory is not initialized; cannot run game");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE);
|
let setup_done_file = lanspread_peer::setup_done_path(&state_dir, &id);
|
||||||
if !first_start_done_file.exists() && game_setup_bin.exists() {
|
if !setup_done_file.exists() && game_setup_bin.exists() {
|
||||||
if !local_install_is_present(&game_path) {
|
if !local_install_is_present(&game_path) {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"local install is missing for {}; skipping game_setup",
|
"local install is missing for {}; skipping game_setup",
|
||||||
@@ -555,6 +578,7 @@ async fn run_game_windows(
|
|||||||
"cmd.exe",
|
"cmd.exe",
|
||||||
&script_params(&game_setup_bin, &id, &settings),
|
&script_params(&game_setup_bin, &id, &settings),
|
||||||
&game_path.display().to_string(),
|
&game_path.display().to_string(),
|
||||||
|
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||||
);
|
);
|
||||||
|
|
||||||
if !result {
|
if !result {
|
||||||
@@ -562,10 +586,19 @@ async fn run_game_windows(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = File::create(&first_start_done_file) {
|
if let Some(parent) = setup_done_file.parent()
|
||||||
|
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||||
|
{
|
||||||
log::error!(
|
log::error!(
|
||||||
"failed to create first-start marker {}: {e}",
|
"failed to create setup marker directory {}: {e}",
|
||||||
first_start_done_file.display()
|
parent.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::File::create(&setup_done_file) {
|
||||||
|
log::error!(
|
||||||
|
"failed to create setup marker {}: {e}",
|
||||||
|
setup_done_file.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,6 +608,7 @@ async fn run_game_windows(
|
|||||||
"cmd.exe",
|
"cmd.exe",
|
||||||
&script_params(&game_start_bin, &id, &settings),
|
&script_params(&game_start_bin, &id, &settings),
|
||||||
&game_path.display().to_string(),
|
&game_path.display().to_string(),
|
||||||
|
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||||
);
|
);
|
||||||
|
|
||||||
if !result {
|
if !result {
|
||||||
@@ -646,8 +680,9 @@ async fn start_server_windows(
|
|||||||
|
|
||||||
let result = run_as_admin(
|
let result = run_as_admin(
|
||||||
"cmd.exe",
|
"cmd.exe",
|
||||||
&script_params(&server_start_bin, &id, &settings),
|
&server_script_params(&server_start_bin, &id, &settings),
|
||||||
&game_path.display().to_string(),
|
&game_path.display().to_string(),
|
||||||
|
windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
|
||||||
);
|
);
|
||||||
|
|
||||||
if !result {
|
if !result {
|
||||||
@@ -850,6 +885,21 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta
|
|||||||
}
|
}
|
||||||
|
|
||||||
let path_changed = current_path != path;
|
let path_changed = current_path != path;
|
||||||
|
let Some(state_dir) = state.state_dir.get().cloned() else {
|
||||||
|
log::error!("app state directory is not initialized; cannot update game directory");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if path_changed || state.peer_ctrl.read().await.is_none() {
|
||||||
|
let migration = migrate_legacy_state(&games_folder, &state_dir).await;
|
||||||
|
if migration.failures > 0 {
|
||||||
|
log::warn!(
|
||||||
|
"Legacy state migration completed with {} failure(s)",
|
||||||
|
migration.failures
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
*state.games_folder.write().await = path;
|
*state.games_folder.write().await = path;
|
||||||
|
|
||||||
ensure_bundled_game_db_loaded(&app_handle).await;
|
ensure_bundled_game_db_loaded(&app_handle).await;
|
||||||
@@ -1193,16 +1243,23 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let Some(state_dir) = state.state_dir.get().cloned() else {
|
||||||
|
log::error!("app state directory is not initialized; cannot start peer");
|
||||||
|
return;
|
||||||
|
};
|
||||||
let tx_peer_event = app_handle.state::<PeerEventTx>().inner().0.clone();
|
let tx_peer_event = app_handle.state::<PeerEventTx>().inner().0.clone();
|
||||||
let unpacker = Arc::new(SidecarUnpacker {
|
let unpacker = Arc::new(SidecarUnpacker {
|
||||||
app_handle: app_handle.clone(),
|
app_handle: app_handle.clone(),
|
||||||
});
|
});
|
||||||
match start_peer(
|
match start_peer_with_options(
|
||||||
games_folder.to_path_buf(),
|
games_folder.to_path_buf(),
|
||||||
tx_peer_event,
|
tx_peer_event,
|
||||||
state.peer_game_db.clone(),
|
state.peer_game_db.clone(),
|
||||||
unpacker,
|
unpacker,
|
||||||
state.catalog.clone(),
|
state.catalog.clone(),
|
||||||
|
PeerStartOptions {
|
||||||
|
state_dir: Some(state_dir),
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Ok(handle) => {
|
Ok(handle) => {
|
||||||
let sender = handle.sender();
|
let sender = handle.sender();
|
||||||
@@ -1621,7 +1678,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn script_params_use_common_argument_shape() {
|
fn script_params_use_common_argument_shape() {
|
||||||
let params = script_params(
|
let start_params = script_params(
|
||||||
Path::new("C:/Games/My Game")
|
Path::new("C:/Games/My Game")
|
||||||
.join(GAME_START_SCRIPT)
|
.join(GAME_START_SCRIPT)
|
||||||
.as_path(),
|
.as_path(),
|
||||||
@@ -1633,9 +1690,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
params,
|
start_params,
|
||||||
r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""#
|
r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""#
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let server_params = server_script_params(
|
||||||
|
Path::new("C:/Games/My Game")
|
||||||
|
.join(SERVER_START_SCRIPT)
|
||||||
|
.as_path(),
|
||||||
|
"my-game",
|
||||||
|
&LaunchSettings {
|
||||||
|
language: "en".to_string(),
|
||||||
|
username: "Alice".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
server_params,
|
||||||
|
r#"/d /s /k ""C:/Games/My Game/server_start.cmd" "local" "my-game" "en" "Alice"""#
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1743,6 +1816,12 @@ pub fn run() {
|
|||||||
.manage(LanSpreadState::default())
|
.manage(LanSpreadState::default())
|
||||||
.manage(PeerEventTx(tx_peer_event))
|
.manage(PeerEventTx(tx_peer_event))
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
let state_dir = app.path().app_data_dir()?;
|
||||||
|
std::fs::create_dir_all(&state_dir)?;
|
||||||
|
let state = app.state::<LanSpreadState>();
|
||||||
|
if state.state_dir.set(state_dir).is_err() {
|
||||||
|
log::warn!("app state directory was already initialized");
|
||||||
|
}
|
||||||
spawn_peer_event_loop(app.handle().clone(), rx_peer_event);
|
spawn_peer_event_loop(app.handle().clone(), rx_peer_event);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user