Compare commits
17 Commits
8c8079fe19
...
4fa4f8f326
| Author | SHA1 | Date | |
|---|---|---|---|
|
4fa4f8f326
|
|||
|
44fee7ff2a
|
|||
|
a375471b94
|
|||
|
5cf70f35bd
|
|||
|
fff1185079
|
|||
|
d102f19dc1
|
|||
|
a7dc1ba0ff
|
|||
|
e688fd3016
|
|||
|
c567ff8afd
|
|||
|
db41793f3a
|
|||
|
c17e61d0df
|
|||
|
065e1586c0
|
|||
|
8402540050
|
|||
|
49ca5c04a2
|
|||
|
f9a709466b
|
|||
|
40e4176246
|
|||
|
e8e7d7a93e
|
Generated
+1
@@ -2209,6 +2209,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
|||||||
+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,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
@@ -237,6 +240,7 @@ async fn handle_command(
|
|||||||
id: game_id.clone(),
|
id: game_id.clone(),
|
||||||
file_descriptions: files,
|
file_descriptions: files,
|
||||||
install_after_download: *install_after_download,
|
install_after_download: *install_after_download,
|
||||||
|
account_name: None,
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
||||||
}
|
}
|
||||||
@@ -245,6 +249,7 @@ async fn handle_command(
|
|||||||
ensure_no_active_operation(shared, game_id).await?;
|
ensure_no_active_operation(shared, game_id).await?;
|
||||||
sender.send(PeerCommand::InstallGame {
|
sender.send(PeerCommand::InstallGame {
|
||||||
id: game_id.clone(),
|
id: game_id.clone(),
|
||||||
|
account_name: None,
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"queued": true, "game_id": game_id}))
|
Ok(json!({"queued": true, "game_id": game_id}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ pub async fn handle_download_game_files_command(
|
|||||||
id: String,
|
id: String,
|
||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
install_after_download: bool,
|
install_after_download: bool,
|
||||||
|
account_name: Option<String>,
|
||||||
) {
|
) {
|
||||||
log::info!("Got PeerCommand::DownloadGameFiles");
|
log::info!("Got PeerCommand::DownloadGameFiles");
|
||||||
if !catalog_contains(ctx, &id).await {
|
if !catalog_contains(ctx, &id).await {
|
||||||
@@ -276,7 +277,7 @@ pub async fn handle_download_game_files_command(
|
|||||||
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
|
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
|
||||||
}
|
}
|
||||||
if install_after_download {
|
if install_after_download {
|
||||||
spawn_install_operation(ctx, tx_notify_ui, id.clone());
|
spawn_install_operation(ctx, tx_notify_ui, id.clone(), account_name);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::error!("No trusted peers available after majority validation for game {id}");
|
log::error!("No trusted peers available after majority validation for game {id}");
|
||||||
@@ -321,7 +322,7 @@ pub async fn handle_download_game_files_command(
|
|||||||
peer_whitelist,
|
peer_whitelist,
|
||||||
file_peer_map,
|
file_peer_map,
|
||||||
tx_notify_ui_clone.clone(),
|
tx_notify_ui_clone.clone(),
|
||||||
cancel_token,
|
cancel_token.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -341,6 +342,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,15 +356,20 @@ 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,
|
||||||
download_id,
|
download_id,
|
||||||
prepared,
|
prepared,
|
||||||
|
account_name,
|
||||||
)
|
)
|
||||||
.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 +382,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(
|
||||||
@@ -392,8 +400,18 @@ 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();
|
||||||
|
let download_was_cancelled = cancel_token.is_cancelled();
|
||||||
|
if download_was_cancelled {
|
||||||
|
log::info!("Download cancelled for {download_id}: {e}");
|
||||||
|
} else {
|
||||||
log::error!("Download failed for {download_id}: {e}");
|
log::error!("Download failed for {download_id}: {e}");
|
||||||
}
|
}
|
||||||
|
send_download_failed_unless_cancelled(
|
||||||
|
&tx_notify_ui_clone,
|
||||||
|
&download_id,
|
||||||
|
download_was_cancelled,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -403,8 +421,9 @@ pub async fn handle_install_game_command(
|
|||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
id: String,
|
id: String,
|
||||||
|
account_name: Option<String>,
|
||||||
) {
|
) {
|
||||||
spawn_install_operation(ctx, tx_notify_ui, id);
|
spawn_install_operation(ctx, tx_notify_ui, id, account_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the `UninstallGame` command.
|
/// Handles the `UninstallGame` command.
|
||||||
@@ -447,15 +466,25 @@ pub async fn handle_cancel_download_command(
|
|||||||
cancel_token.cancel();
|
cancel_token.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
fn spawn_install_operation(
|
||||||
|
ctx: &Ctx,
|
||||||
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
|
id: String,
|
||||||
|
account_name: Option<String>,
|
||||||
|
) {
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
let tx_notify_ui = tx_notify_ui.clone();
|
let tx_notify_ui = tx_notify_ui.clone();
|
||||||
ctx.task_tracker.clone().spawn(async move {
|
ctx.task_tracker.clone().spawn(async move {
|
||||||
run_install_operation(&ctx, &tx_notify_ui, id).await;
|
run_install_operation(&ctx, &tx_notify_ui, id, account_name).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
async fn run_install_operation(
|
||||||
|
ctx: &Ctx,
|
||||||
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
|
id: String,
|
||||||
|
account_name: Option<String>,
|
||||||
|
) {
|
||||||
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -465,7 +494,7 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
|
run_started_install_operation(ctx, tx_notify_ui, id, prepared, account_name).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PreparedInstallOperation {
|
struct PreparedInstallOperation {
|
||||||
@@ -517,6 +546,7 @@ async fn run_started_install_operation(
|
|||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
id: String,
|
id: String,
|
||||||
prepared: PreparedInstallOperation,
|
prepared: PreparedInstallOperation,
|
||||||
|
account_name: Option<String>,
|
||||||
) {
|
) {
|
||||||
let PreparedInstallOperation {
|
let PreparedInstallOperation {
|
||||||
game_root,
|
game_root,
|
||||||
@@ -538,12 +568,27 @@ 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(),
|
||||||
|
account_name.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
InstallOperation::Updating => {
|
InstallOperation::Updating => {
|
||||||
install::update(&game_root, &id, ctx.unpacker.clone()).await
|
install::update(
|
||||||
|
&game_root,
|
||||||
|
state_dir,
|
||||||
|
&id,
|
||||||
|
ctx.unpacker.clone(),
|
||||||
|
account_name.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -601,7 +646,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 +812,31 @@ 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_download_failed_unless_cancelled(
|
||||||
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
|
id: &str,
|
||||||
|
cancelled: bool,
|
||||||
|
) -> bool {
|
||||||
|
if cancelled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
send_download_failed(tx_notify_ui, id);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
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 +915,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 +940,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 +954,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 +1138,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(),
|
||||||
@@ -1076,6 +1147,29 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_download_error_does_not_emit_failed_event() {
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
let emitted = send_download_failed_unless_cancelled(&tx, "game", true);
|
||||||
|
|
||||||
|
assert!(!emitted);
|
||||||
|
assert!(rx.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uncancelled_download_error_emits_failed_event() {
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
let emitted = send_download_failed_unless_cancelled(&tx, "game", false);
|
||||||
|
|
||||||
|
assert!(emitted);
|
||||||
|
assert!(matches!(
|
||||||
|
rx.try_recv(),
|
||||||
|
Ok(PeerEvent::DownloadGameFilesFailed { id }) if id == "game"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
async fn recv_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) -> PeerEvent {
|
async fn recv_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) -> PeerEvent {
|
||||||
tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||||
.await
|
.await
|
||||||
@@ -1332,7 +1426,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 +1472,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,13 +1497,13 @@ 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;
|
||||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
||||||
|
|
||||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||||
|
|
||||||
assert_active_update(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1440,7 +1534,7 @@ mod tests {
|
|||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||||
|
|
||||||
assert_active_update(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1494,7 +1588,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
);
|
);
|
||||||
clear_active_download(&ctx, "game").await;
|
clear_active_download(&ctx, "game").await;
|
||||||
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await;
|
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared, None).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1561,7 +1655,7 @@ mod tests {
|
|||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||||
|
|
||||||
assert_active_update(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1593,7 +1687,7 @@ mod tests {
|
|||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||||
assert_active_update(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
active_update("game", ActiveOperationKind::Installing),
|
active_update("game", ActiveOperationKind::Installing),
|
||||||
@@ -1616,7 +1710,7 @@ mod tests {
|
|||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
write_file(&root.join("game.eti"), b"new archive");
|
write_file(&root.join("game.eti"), b"new archive");
|
||||||
|
|
||||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
run_install_operation(&ctx, &tx, "game".to_string(), None).await;
|
||||||
assert_active_update(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
active_update("game", ActiveOperationKind::Updating),
|
active_update("game", ActiveOperationKind::Updating),
|
||||||
@@ -1695,7 +1789,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 +1875,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;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
collections::HashSet,
|
||||||
|
ffi::OsStr,
|
||||||
io::ErrorKind,
|
io::ErrorKind,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
@@ -19,6 +20,7 @@ const BACKUP_DIR: &str = ".local.backup";
|
|||||||
const OWNED_MARKER: &str = ".lanspread_owned";
|
const OWNED_MARKER: &str = ".lanspread_owned";
|
||||||
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
||||||
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
||||||
|
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum FsEntryState {
|
enum FsEntryState {
|
||||||
@@ -33,18 +35,25 @@ 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>,
|
||||||
|
account_name: Option<&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::Installing, eti_version.clone()),
|
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let result = install_inner(game_root, id, unpacker).await;
|
let result = install_inner(game_root, id, unpacker, account_name).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,24 +63,31 @@ 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>,
|
||||||
|
account_name: Option<&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::Updating, eti_version.clone()),
|
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let result = update_inner(game_root, id, unpacker).await;
|
let result = update_inner(game_root, id, unpacker, account_name).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 +98,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 +107,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 +119,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 +127,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 +162,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(())
|
||||||
}
|
}
|
||||||
@@ -165,6 +192,7 @@ async fn install_inner(
|
|||||||
game_root: &Path,
|
game_root: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
|
account_name: Option<&str>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let local = local_dir(game_root);
|
let local = local_dir(game_root);
|
||||||
if path_is_dir(&local).await {
|
if path_is_dir(&local).await {
|
||||||
@@ -174,13 +202,19 @@ async fn install_inner(
|
|||||||
let staging = installing_dir(game_root);
|
let staging = installing_dir(game_root);
|
||||||
prepare_owned_empty_dir(&staging).await?;
|
prepare_owned_empty_dir(&staging).await?;
|
||||||
unpack_archives(game_root, &staging, unpacker).await?;
|
unpack_archives(game_root, &staging, unpacker).await?;
|
||||||
|
write_account_name_if_present(&staging, account_name).await?;
|
||||||
tokio::fs::rename(&staging, &local)
|
tokio::fs::rename(&staging, &local)
|
||||||
.await
|
.await
|
||||||
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
|
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
async fn update_inner(
|
||||||
|
game_root: &Path,
|
||||||
|
id: &str,
|
||||||
|
unpacker: Arc<dyn Unpacker>,
|
||||||
|
account_name: Option<&str>,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
let local = local_dir(game_root);
|
let local = local_dir(game_root);
|
||||||
let backup = backup_dir(game_root);
|
let backup = backup_dir(game_root);
|
||||||
let staging = installing_dir(game_root);
|
let staging = installing_dir(game_root);
|
||||||
@@ -196,6 +230,7 @@ async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -
|
|||||||
|
|
||||||
prepare_owned_empty_dir(&staging).await?;
|
prepare_owned_empty_dir(&staging).await?;
|
||||||
unpack_archives(game_root, &staging, unpacker).await?;
|
unpack_archives(game_root, &staging, unpacker).await?;
|
||||||
|
write_account_name_if_present(&staging, account_name).await?;
|
||||||
tokio::fs::rename(&staging, &local)
|
tokio::fs::rename(&staging, &local)
|
||||||
.await
|
.await
|
||||||
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
|
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
|
||||||
@@ -249,6 +284,48 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
|||||||
Ok(archives)
|
Ok(archives)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn write_account_name_if_present(
|
||||||
|
install_root: &Path,
|
||||||
|
account_name: Option<&str>,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
|
let Some(account_name) = account_name else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(path) = find_account_name_file(install_root).await? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::fs::write(&path, account_name)
|
||||||
|
.await
|
||||||
|
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_account_name_file(root: &Path) -> eyre::Result<Option<PathBuf>> {
|
||||||
|
let mut pending_dirs = vec![root.to_path_buf()];
|
||||||
|
while let Some(dir) = pending_dirs.pop() {
|
||||||
|
let mut entries = tokio::fs::read_dir(&dir).await?;
|
||||||
|
let mut child_dirs = Vec::new();
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
let file_type = entry.file_type().await?;
|
||||||
|
let path = entry.path();
|
||||||
|
if entry.file_name() == OsStr::new(ACCOUNT_NAME_FILE) && file_type.is_file() {
|
||||||
|
return Ok(Some(path));
|
||||||
|
}
|
||||||
|
if file_type.is_dir() {
|
||||||
|
child_dirs.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child_dirs.sort();
|
||||||
|
child_dirs.reverse();
|
||||||
|
pending_dirs.extend(child_dirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
||||||
sweep_owned_orphan(&installing_dir(game_root)).await?;
|
sweep_owned_orphan(&installing_dir(game_root)).await?;
|
||||||
sweep_owned_orphan(&backup_dir(game_root)).await?;
|
sweep_owned_orphan(&backup_dir(game_root)).await?;
|
||||||
@@ -257,6 +334,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 +346,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 +380,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 +403,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 +496,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 +521,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 +618,61 @@ 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(), None)
|
||||||
.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]
|
||||||
|
async fn install_account_name_missing_file_is_noop() {
|
||||||
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
let state = test_state();
|
||||||
|
let root = temp.game_root();
|
||||||
|
write_file(&root.join("game.eti"), b"archive");
|
||||||
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
|
|
||||||
|
install(
|
||||||
|
&root,
|
||||||
|
state.path(),
|
||||||
|
"game",
|
||||||
|
successful_unpacker(),
|
||||||
|
Some("Alice"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("install should succeed without account file");
|
||||||
|
|
||||||
|
assert!(root.join("local").join("payload.txt").is_file());
|
||||||
|
assert!(!root.join("local").join(ACCOUNT_NAME_FILE).exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[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(), None)
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
|
|
||||||
@@ -570,15 +686,66 @@ mod tests {
|
|||||||
assert_eq!(archives, vec!["a.eti", "b.eti"]);
|
assert_eq!(archives, vec!["a.eti", "b.eti"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn install_overwrites_first_account_name_file() {
|
||||||
|
struct AccountNameUnpacker;
|
||||||
|
|
||||||
|
impl Unpacker for AccountNameUnpacker {
|
||||||
|
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||||
|
Box::pin(async move {
|
||||||
|
tokio::fs::create_dir_all(dest.join("a")).await?;
|
||||||
|
tokio::fs::create_dir_all(dest.join("z")).await?;
|
||||||
|
tokio::fs::write(dest.join("a").join(ACCOUNT_NAME_FILE), b"old-a").await?;
|
||||||
|
tokio::fs::write(dest.join("z").join(ACCOUNT_NAME_FILE), b"old-z").await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp = TempDir::new("lanspread-install");
|
||||||
|
let state = test_state();
|
||||||
|
let root = temp.game_root();
|
||||||
|
write_file(&root.join("game.eti"), b"archive");
|
||||||
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
|
|
||||||
|
install(
|
||||||
|
&root,
|
||||||
|
state.path(),
|
||||||
|
"game",
|
||||||
|
Arc::new(AccountNameUnpacker),
|
||||||
|
Some("Alice"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("install should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(root.join("local").join("a").join(ACCOUNT_NAME_FILE))
|
||||||
|
.expect("first account file should be readable"),
|
||||||
|
"Alice"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(root.join("local").join("z").join(ACCOUNT_NAME_FILE))
|
||||||
|
.expect("second account file should be readable"),
|
||||||
|
"old-z"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[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()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("update should fail");
|
.expect_err("update should fail");
|
||||||
|
|
||||||
@@ -586,19 +753,26 @@ 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()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("update should fail at commit rename");
|
.expect_err("update should fail at commit rename");
|
||||||
|
|
||||||
@@ -614,19 +788,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(), None)
|
||||||
.await
|
.await
|
||||||
.expect("update should succeed");
|
.expect("update should succeed");
|
||||||
|
|
||||||
@@ -634,19 +809,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 +837,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 +846,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 +874,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 +1021,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 +1053,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 +1067,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 +1083,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
|
||||||
@@ -225,15 +229,20 @@ pub enum PeerCommand {
|
|||||||
DownloadGameFiles {
|
DownloadGameFiles {
|
||||||
id: String,
|
id: String,
|
||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
|
account_name: Option<String>,
|
||||||
},
|
},
|
||||||
/// Download game files with an explicit install policy.
|
/// Download game files with an explicit install policy.
|
||||||
DownloadGameFilesWithOptions {
|
DownloadGameFilesWithOptions {
|
||||||
id: String,
|
id: String,
|
||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
install_after_download: bool,
|
install_after_download: bool,
|
||||||
|
account_name: Option<String>,
|
||||||
},
|
},
|
||||||
/// Install already-downloaded archives into `local/`.
|
/// Install already-downloaded archives into `local/`.
|
||||||
InstallGame { id: String },
|
InstallGame {
|
||||||
|
id: String,
|
||||||
|
account_name: Option<String>,
|
||||||
|
},
|
||||||
/// Remove only the `local/` install for a game.
|
/// Remove only the `local/` install for a game.
|
||||||
UninstallGame { id: String },
|
UninstallGame { id: String },
|
||||||
/// Remove downloaded archive files for an uninstalled game.
|
/// Remove downloaded archive files for an uninstalled game.
|
||||||
@@ -300,12 +309,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 +326,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 +340,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 +350,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,
|
||||||
@@ -397,14 +410,23 @@ async fn handle_peer_commands(
|
|||||||
PeerCommand::DownloadGameFiles {
|
PeerCommand::DownloadGameFiles {
|
||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
|
account_name,
|
||||||
} => {
|
} => {
|
||||||
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true)
|
handle_download_game_files_command(
|
||||||
|
ctx,
|
||||||
|
tx_notify_ui,
|
||||||
|
id,
|
||||||
|
file_descriptions,
|
||||||
|
true,
|
||||||
|
account_name,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
PeerCommand::DownloadGameFilesWithOptions {
|
PeerCommand::DownloadGameFilesWithOptions {
|
||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
install_after_download,
|
install_after_download,
|
||||||
|
account_name,
|
||||||
} => {
|
} => {
|
||||||
handle_download_game_files_command(
|
handle_download_game_files_command(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -412,11 +434,12 @@ async fn handle_peer_commands(
|
|||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
install_after_download,
|
install_after_download,
|
||||||
|
account_name,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
PeerCommand::InstallGame { id } => {
|
PeerCommand::InstallGame { id, account_name } => {
|
||||||
handle_install_game_command(ctx, tx_notify_ui, id).await;
|
handle_install_game_command(ctx, tx_notify_ui, id, account_name).await;
|
||||||
}
|
}
|
||||||
PeerCommand::UninstallGame { id } => {
|
PeerCommand::UninstallGame { id } => {
|
||||||
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ eyre = { workspace = true }
|
|||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
mimalloc = { workspace = true }
|
mimalloc = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
tauri = { workspace = true }
|
tauri = { workspace = true }
|
||||||
tauri-plugin-log = { workspace = true }
|
tauri-plugin-log = { workspace = true }
|
||||||
tauri-plugin-shell = { workspace = true }
|
tauri-plugin-shell = { workspace = true }
|
||||||
|
|||||||
@@ -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,8 @@ struct LanSpreadState {
|
|||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<HashSet<String>>>,
|
||||||
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
||||||
|
pending_install_account_names: Arc<RwLock<HashMap<String, String>>>,
|
||||||
|
state_dir: OnceLock<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
||||||
@@ -74,7 +76,7 @@ struct LauncherGame {
|
|||||||
can_host_server: bool,
|
can_host_server: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||||
struct UnpackLogEntry {
|
struct UnpackLogEntry {
|
||||||
archive: String,
|
archive: String,
|
||||||
destination: String,
|
destination: String,
|
||||||
@@ -90,7 +92,8 @@ struct SidecarUnpacker {
|
|||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_UNPACK_LOGS: usize = 100;
|
const MAX_UNPACK_LOGS: usize = 20;
|
||||||
|
const UNPACK_LOGS_FILE_NAME: &str = "unpack-logs.json";
|
||||||
|
|
||||||
impl Unpacker for SidecarUnpacker {
|
impl Unpacker for SidecarUnpacker {
|
||||||
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||||
@@ -109,8 +112,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))]
|
||||||
@@ -142,7 +143,11 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
|
async fn install_game(
|
||||||
|
id: String,
|
||||||
|
username: String,
|
||||||
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
|
) -> tauri::Result<bool> {
|
||||||
if state
|
if state
|
||||||
.inner()
|
.inner()
|
||||||
.active_operations
|
.active_operations
|
||||||
@@ -168,11 +173,21 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let account_name = sanitize_username(&username);
|
||||||
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
||||||
let command = if !downloaded {
|
let command = if !downloaded {
|
||||||
PeerCommand::GetGame(id)
|
state
|
||||||
|
.inner()
|
||||||
|
.pending_install_account_names
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(id.clone(), account_name);
|
||||||
|
PeerCommand::GetGame(id.clone())
|
||||||
} else if !installed {
|
} else if !installed {
|
||||||
PeerCommand::InstallGame { id }
|
PeerCommand::InstallGame {
|
||||||
|
id: id.clone(),
|
||||||
|
account_name: Some(account_name),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!("Game is already installed: {id}");
|
log::info!("Game is already installed: {id}");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -180,6 +195,13 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
|
|||||||
|
|
||||||
if let Err(e) = peer_ctrl.send(command) {
|
if let Err(e) = peer_ctrl.send(command) {
|
||||||
log::error!("Failed to send message to peer: {e:?}");
|
log::error!("Failed to send message to peer: {e:?}");
|
||||||
|
state
|
||||||
|
.inner()
|
||||||
|
.pending_install_account_names
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.remove(&id);
|
||||||
|
return Ok(false);
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
@@ -191,7 +213,11 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
|
async fn update_game(
|
||||||
|
id: String,
|
||||||
|
username: String,
|
||||||
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
|
) -> tauri::Result<bool> {
|
||||||
if state
|
if state
|
||||||
.inner()
|
.inner()
|
||||||
.active_operations
|
.active_operations
|
||||||
@@ -208,8 +234,20 @@ async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tau
|
|||||||
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
||||||
|
|
||||||
if let Some(peer_ctrl) = peer_ctrl {
|
if let Some(peer_ctrl) = peer_ctrl {
|
||||||
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id }) {
|
state
|
||||||
|
.inner()
|
||||||
|
.pending_install_account_names
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(id.clone(), sanitize_username(&username));
|
||||||
|
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
|
||||||
log::error!("Failed to send message to peer: {e:?}");
|
log::error!("Failed to send message to peer: {e:?}");
|
||||||
|
state
|
||||||
|
.inner()
|
||||||
|
.pending_install_account_names
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.remove(&id);
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -302,6 +340,13 @@ async fn cancel_download(
|
|||||||
id: String,
|
id: String,
|
||||||
state: tauri::State<'_, LanSpreadState>,
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
) -> tauri::Result<bool> {
|
) -> tauri::Result<bool> {
|
||||||
|
state
|
||||||
|
.inner()
|
||||||
|
.pending_install_account_names
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.remove(&id);
|
||||||
|
|
||||||
let is_active_download = {
|
let is_active_download = {
|
||||||
let active_operations = state.inner().active_operations.read().await;
|
let active_operations = state.inner().active_operations.read().await;
|
||||||
matches!(
|
matches!(
|
||||||
@@ -441,8 +486,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 +547,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 +569,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 +605,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 +624,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 +632,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 +654,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 +726,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 {
|
||||||
@@ -821,6 +902,11 @@ fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn game_directory_exists(path: String) -> bool {
|
||||||
|
PathBuf::from(path).is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
|
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
|
||||||
log::info!("update_game_directory: {path}");
|
log::info!("update_game_directory: {path}");
|
||||||
@@ -850,6 +936,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;
|
||||||
@@ -1065,8 +1166,8 @@ async fn run_unrar_sidecar(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout));
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr));
|
||||||
let status_code = out.status.code();
|
let status_code = out.status.code();
|
||||||
let success = out.status.success();
|
let success = out.status.success();
|
||||||
|
|
||||||
@@ -1123,17 +1224,117 @@ async fn record_unpack_failure(
|
|||||||
|
|
||||||
async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
|
async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
|
||||||
let state = app_handle.state::<LanSpreadState>();
|
let state = app_handle.state::<LanSpreadState>();
|
||||||
{
|
let mut entry = entry;
|
||||||
|
clean_unpack_log_entry(&mut entry);
|
||||||
|
let logs = {
|
||||||
let mut logs = state.inner().unpack_logs.write().await;
|
let mut logs = state.inner().unpack_logs.write().await;
|
||||||
logs.push(entry);
|
logs.push(entry);
|
||||||
|
trim_unpack_logs(&mut logs);
|
||||||
|
logs.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
persist_unpack_logs(app_handle, &logs).await;
|
||||||
|
|
||||||
|
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
|
||||||
|
log::warn!("Failed to emit unpack-logs-updated event: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_unpack_logs(logs: &mut Vec<UnpackLogEntry>) {
|
||||||
if logs.len() > MAX_UNPACK_LOGS {
|
if logs.len() > MAX_UNPACK_LOGS {
|
||||||
let overflow = logs.len() - MAX_UNPACK_LOGS;
|
let overflow = logs.len() - MAX_UNPACK_LOGS;
|
||||||
logs.drain(..overflow);
|
logs.drain(..overflow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
|
fn clean_unpack_log_entry(entry: &mut UnpackLogEntry) {
|
||||||
log::warn!("Failed to emit unpack-logs-updated event: {err}");
|
let stdout = clean_terminal_log(&entry.stdout);
|
||||||
|
let stderr = clean_terminal_log(&entry.stderr);
|
||||||
|
entry.stdout = stdout;
|
||||||
|
entry.stderr = stderr;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_terminal_log(input: &str) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut line = String::new();
|
||||||
|
let mut chars = input.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
match ch {
|
||||||
|
'\r' if chars.peek() == Some(&'\n') => {
|
||||||
|
let _ = chars.next();
|
||||||
|
output.push_str(&line);
|
||||||
|
output.push('\n');
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
'\r' => {
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
'\n' => {
|
||||||
|
output.push_str(&line);
|
||||||
|
output.push('\n');
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
'\u{8}' => {
|
||||||
|
let _ = line.pop();
|
||||||
|
}
|
||||||
|
'\t' => line.push(ch),
|
||||||
|
ch if ch.is_control() => {}
|
||||||
|
ch => line.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str(&line);
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unpack_logs_path(state_dir: &Path) -> PathBuf {
|
||||||
|
state_dir.join(UNPACK_LOGS_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_unpack_logs(state_dir: &Path) -> Vec<UnpackLogEntry> {
|
||||||
|
let path = unpack_logs_path(state_dir);
|
||||||
|
let contents = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(contents) => contents,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Failed to read unpack logs from {}: {err}", path.display());
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut logs = match serde_json::from_str::<Vec<UnpackLogEntry>>(&contents) {
|
||||||
|
Ok(logs) => logs,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Failed to parse unpack logs from {}: {err}", path.display());
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
logs.iter_mut().for_each(clean_unpack_log_entry);
|
||||||
|
trim_unpack_logs(&mut logs);
|
||||||
|
logs
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_unpack_logs(app_handle: &AppHandle, logs: &[UnpackLogEntry]) {
|
||||||
|
let state = app_handle.state::<LanSpreadState>();
|
||||||
|
let Some(state_dir) = state.state_dir.get().cloned() else {
|
||||||
|
log::warn!("Cannot persist unpack logs before app state directory is initialized");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let path = unpack_logs_path(&state_dir);
|
||||||
|
let contents = match serde_json::to_vec_pretty(logs) {
|
||||||
|
Ok(contents) => contents,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to serialize unpack logs for {}: {err}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = tokio::fs::write(&path, contents).await {
|
||||||
|
log::warn!("Failed to persist unpack logs to {}: {err}", path.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1193,16 +1394,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();
|
||||||
@@ -1273,6 +1481,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
PeerEvent::NoPeersHaveGame { id } => {
|
PeerEvent::NoPeersHaveGame { id } => {
|
||||||
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
|
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
|
||||||
|
clear_pending_install_account_name(app_handle, &id).await;
|
||||||
emit_game_id_event(
|
emit_game_id_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"game-no-peers",
|
"game-no-peers",
|
||||||
@@ -1311,6 +1520,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
PeerEvent::DownloadGameFilesFailed { id } => {
|
PeerEvent::DownloadGameFilesFailed { id } => {
|
||||||
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
||||||
|
clear_pending_install_account_name(app_handle, &id).await;
|
||||||
emit_game_id_event(
|
emit_game_id_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"game-download-failed",
|
"game-download-failed",
|
||||||
@@ -1320,6 +1530,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
||||||
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
|
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
|
||||||
|
clear_pending_install_account_name(app_handle, &id).await;
|
||||||
emit_game_id_event(
|
emit_game_id_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"game-download-peers-gone",
|
"game-download-peers-gone",
|
||||||
@@ -1444,6 +1655,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn clear_pending_install_account_name(app_handle: &AppHandle, id: &str) {
|
||||||
|
let state = app_handle.state::<LanSpreadState>();
|
||||||
|
state.pending_install_account_names.write().await.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_got_game_files(
|
async fn handle_got_game_files(
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
id: String,
|
id: String,
|
||||||
@@ -1458,11 +1674,17 @@ async fn handle_got_game_files(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let state = app_handle.state::<LanSpreadState>();
|
let state = app_handle.state::<LanSpreadState>();
|
||||||
|
let account_name = state
|
||||||
|
.pending_install_account_names
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.remove(&id);
|
||||||
let peer_ctrl = state.peer_ctrl.read().await.clone();
|
let peer_ctrl = state.peer_ctrl.read().await.clone();
|
||||||
if let Some(peer_ctrl) = peer_ctrl
|
if let Some(peer_ctrl) = peer_ctrl
|
||||||
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
|
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
|
||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
|
account_name,
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
||||||
@@ -1483,6 +1705,20 @@ fn handle_download_finished(app_handle: &AppHandle, id: String) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn unpack_log_fixture(index: usize) -> UnpackLogEntry {
|
||||||
|
let timestamp = u64::try_from(index).unwrap_or(u64::MAX);
|
||||||
|
UnpackLogEntry {
|
||||||
|
archive: format!("archive-{index}.rar"),
|
||||||
|
destination: format!("destination-{index}"),
|
||||||
|
status_code: Some(0),
|
||||||
|
stdout: format!("stdout {index}"),
|
||||||
|
stderr: String::new(),
|
||||||
|
started_at_ms: timestamp,
|
||||||
|
finished_at_ms: timestamp,
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn game_fixture(id: &str, name: &str) -> Game {
|
fn game_fixture(id: &str, name: &str) -> Game {
|
||||||
Game {
|
Game {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -1503,6 +1739,72 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() {
|
||||||
|
let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n";
|
||||||
|
|
||||||
|
assert_eq!(clean_terminal_log(input), "Extracting foo OK\nAll done\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_log_cleanup_applies_backspaces() {
|
||||||
|
assert_eq!(clean_terminal_log("abc\u{8}\u{8}de\n"), "ade\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_log_cleanup_removes_other_controls() {
|
||||||
|
assert_eq!(clean_terminal_log("a\u{7}b\tc"), "ab\tc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unpack_log_retention_keeps_last_twenty_entries() {
|
||||||
|
let mut logs = (0..25).map(unpack_log_fixture).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
trim_unpack_logs(&mut logs);
|
||||||
|
|
||||||
|
assert_eq!(logs.len(), MAX_UNPACK_LOGS);
|
||||||
|
assert_eq!(
|
||||||
|
logs.first().map(|entry| entry.archive.as_str()),
|
||||||
|
Some("archive-5.rar")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
logs.last().map(|entry| entry.archive.as_str()),
|
||||||
|
Some("archive-24.rar")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unpack_logs_load_from_app_state_dir_and_apply_retention() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"lanspread-unpack-logs-test-{}",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock should be after epoch")
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&root).expect("test state dir should be created");
|
||||||
|
let logs = (0..25).map(unpack_log_fixture).collect::<Vec<_>>();
|
||||||
|
std::fs::write(
|
||||||
|
unpack_logs_path(&root),
|
||||||
|
serde_json::to_vec(&logs).expect("logs should serialize"),
|
||||||
|
)
|
||||||
|
.expect("logs should be written");
|
||||||
|
|
||||||
|
let loaded = load_unpack_logs(&root);
|
||||||
|
|
||||||
|
assert_eq!(loaded.len(), MAX_UNPACK_LOGS);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.first().map(|entry| entry.archive.as_str()),
|
||||||
|
Some("archive-5.rar")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.last().map(|entry| entry.archive.as_str()),
|
||||||
|
Some("archive-24.rar")
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn active_operation_reconciliation_replaces_stale_ui_history() {
|
fn active_operation_reconciliation_replaces_stale_ui_history() {
|
||||||
let mut active_operations = HashMap::from([
|
let mut active_operations = HashMap::from([
|
||||||
@@ -1621,7 +1923,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 +1935,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]
|
||||||
@@ -1730,6 +2048,7 @@ pub fn run() {
|
|||||||
install_game,
|
install_game,
|
||||||
run_game,
|
run_game,
|
||||||
start_server,
|
start_server,
|
||||||
|
game_directory_exists,
|
||||||
update_game_directory,
|
update_game_directory,
|
||||||
update_game,
|
update_game,
|
||||||
uninstall_game,
|
uninstall_game,
|
||||||
@@ -1743,6 +2062,16 @@ 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>();
|
||||||
|
let unpack_logs = load_unpack_logs(&state_dir);
|
||||||
|
tauri::async_runtime::block_on(async {
|
||||||
|
*state.unpack_logs.write().await = unpack_logs;
|
||||||
|
});
|
||||||
|
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(())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const formatLogTime = (timestampMs: number): string => {
|
|||||||
return new Date(timestampMs).toLocaleString();
|
return new Date(timestampMs).toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logSortTime = (entry: UnpackLogEntry): number =>
|
||||||
|
entry.finished_at_ms > 0 ? entry.finished_at_ms : entry.started_at_ms;
|
||||||
|
|
||||||
const basename = (path: string): string => {
|
const basename = (path: string): string => {
|
||||||
const segments = path.split(/[\\/]/);
|
const segments = path.split(/[\\/]/);
|
||||||
return segments[segments.length - 1] || path;
|
return segments[segments.length - 1] || path;
|
||||||
@@ -97,6 +100,10 @@ export const UnpackLogsWindow = () => {
|
|||||||
|
|
||||||
out.push({ entry, originalIndex, stdoutLines, stderrLines, matchCount });
|
out.push({ entry, originalIndex, stdoutLines, stderrLines, matchCount });
|
||||||
});
|
});
|
||||||
|
out.sort((a, b) => {
|
||||||
|
const timestampDelta = logSortTime(b.entry) - logSortTime(a.entry);
|
||||||
|
return timestampDelta !== 0 ? timestampDelta : b.originalIndex - a.originalIndex;
|
||||||
|
});
|
||||||
return out;
|
return out;
|
||||||
}, [logs, errorsOnly, regex]);
|
}, [logs, errorsOnly, regex]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ interface Props {
|
|||||||
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
|
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon"><Icon.folder /></div>
|
<div className="empty-state-icon"><Icon.folder /></div>
|
||||||
<h2 className="empty-state-title">Pick a game directory</h2>
|
<h2 className="empty-state-title">Please select a game folder</h2>
|
||||||
<p className="empty-state-hint">
|
|
||||||
SoftLAN scans the folder you point it at for installable game bundles
|
|
||||||
and tracks what your peers on the LAN have available.
|
|
||||||
</p>
|
|
||||||
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
|
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
|
||||||
<Icon.folder />
|
<Icon.folder />
|
||||||
<span>Choose folder</span>
|
<span>Choose folder</span>
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export const GameDetailModal = ({
|
|||||||
&& !isInProgress(game.install_status);
|
&& !isInProgress(game.install_status);
|
||||||
const canViewFiles = game.downloaded
|
const canViewFiles = game.downloaded
|
||||||
|| game.installed
|
|| game.installed
|
||||||
|| game.install_status === InstallStatus.Downloading;
|
|| game.install_status === InstallStatus.Downloading
|
||||||
|
|| game.install_status === InstallStatus.Installing;
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose}>
|
<Modal onClose={onClose}>
|
||||||
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ import {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: UISettings;
|
settings: UISettings;
|
||||||
|
gameDir: string;
|
||||||
|
hasGameDirectory: boolean;
|
||||||
|
onPickDirectory: () => void;
|
||||||
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildNr = import.meta.env.VITE_LANSPREAD_BUILD_NR;
|
||||||
|
|
||||||
interface RowProps {
|
interface RowProps {
|
||||||
label: string;
|
label: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
@@ -54,7 +59,37 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
interface GameFolderFieldProps {
|
||||||
|
path: string;
|
||||||
|
isValid: boolean;
|
||||||
|
onPickDirectory: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameFolderField = ({ path, isValid, onPickDirectory }: GameFolderFieldProps) => (
|
||||||
|
<div className={`folder-field ${isValid ? 'is-set' : 'is-unset'}`}>
|
||||||
|
<Icon.folder className="folder-field-icon" aria-hidden="true" />
|
||||||
|
<div className="folder-field-path" title={isValid ? path : 'No folder selected'}>
|
||||||
|
{isValid ? <bdi>{path}</bdi> : <span className="folder-field-empty">Not set</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="folder-field-btn"
|
||||||
|
aria-label={isValid ? 'Change game folder' : 'Choose game folder'}
|
||||||
|
onClick={onPickDirectory}
|
||||||
|
>
|
||||||
|
{isValid ? 'Change…' : 'Choose…'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SettingsDialog = ({
|
||||||
|
settings,
|
||||||
|
gameDir,
|
||||||
|
hasGameDirectory,
|
||||||
|
onPickDirectory,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
}: Props) => (
|
||||||
<Modal onClose={onClose} className="settings-modal">
|
<Modal onClose={onClose} className="settings-modal">
|
||||||
<div className="settings-head">
|
<div className="settings-head">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
@@ -108,6 +143,13 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
|||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<div className="settings-section-title">Library</div>
|
<div className="settings-section-title">Library</div>
|
||||||
|
<Row label="Game folder" hint="Parent directory where games are downloaded and installed">
|
||||||
|
<GameFolderField
|
||||||
|
path={gameDir}
|
||||||
|
isValid={hasGameDirectory}
|
||||||
|
onPickDirectory={onPickDirectory}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
<Row label="Grid density" hint="How tightly cards are packed">
|
<Row label="Grid density" hint="How tightly cards are packed">
|
||||||
<SegmentedRadio
|
<SegmentedRadio
|
||||||
value={settings.density}
|
value={settings.density}
|
||||||
@@ -126,6 +168,7 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-foot">
|
<div className="settings-foot">
|
||||||
|
<div className="settings-build-nr">Build-Nr: {buildNr}</div>
|
||||||
<button type="button" className="settings-done" onClick={onClose}>Done</button>
|
<button type="button" className="settings-done" onClick={onClose}>Done</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Icon } from '../Icon';
|
|
||||||
import { truncatePath } from '../../lib/format';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
path: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DirectoryButton = ({ path, onClick }: Props) => (
|
|
||||||
<button className="dirbtn" type="button" title={path || 'Choose a game directory'} onClick={onClick}>
|
|
||||||
<Icon.folder />
|
|
||||||
<span className="dirbtn-label">Game directory</span>
|
|
||||||
<span className="dirbtn-path">
|
|
||||||
{path ? truncatePath(path) : 'choose…'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
@@ -8,15 +8,18 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search input with a `/` keyboard shortcut for focus. Ignores the shortcut
|
* Search input with `/` and Ctrl+F keyboard shortcuts for focus. Ignores
|
||||||
* when the user is already typing into another input or textarea.
|
* shortcuts when the user is already typing into another input or textarea.
|
||||||
*/
|
*/
|
||||||
export const SearchField = ({ value, onChange }: Props) => {
|
export const SearchField = ({ value, onChange }: Props) => {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const clearClassName = value ? 'search-clear' : 'search-clear is-hidden';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key !== '/') return;
|
const isFindShortcut = e.ctrlKey && !e.altKey && !e.shiftKey
|
||||||
|
&& e.key.toLowerCase() === 'f';
|
||||||
|
if (e.key !== '/' && !isFindShortcut) return;
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -49,11 +52,12 @@ export const SearchField = ({ value, onChange }: Props) => {
|
|||||||
}}
|
}}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
{value && (
|
|
||||||
<button
|
<button
|
||||||
className="search-clear"
|
className={clearClassName}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
|
aria-hidden={value ? undefined : true}
|
||||||
|
disabled={!value}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange('');
|
onChange('');
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -61,7 +65,6 @@ export const SearchField = ({ value, onChange }: Props) => {
|
|||||||
>
|
>
|
||||||
<Icon.clearCircle />
|
<Icon.clearCircle />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<span className="search-kbd">/</span>
|
<span className="search-kbd">/</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Brand } from '../Brand';
|
|||||||
import { SegmentedFilters } from './SegmentedFilters';
|
import { SegmentedFilters } from './SegmentedFilters';
|
||||||
import { SearchField } from './SearchField';
|
import { SearchField } from './SearchField';
|
||||||
import { SortMenu } from './SortMenu';
|
import { SortMenu } from './SortMenu';
|
||||||
import { DirectoryButton } from './DirectoryButton';
|
|
||||||
import { KebabMenu, KebabItem } from './KebabMenu';
|
import { KebabMenu, KebabItem } from './KebabMenu';
|
||||||
|
|
||||||
import { FilterCounts } from '../../lib/gameState';
|
import { FilterCounts } from '../../lib/gameState';
|
||||||
@@ -17,8 +16,6 @@ interface Props {
|
|||||||
setQuery: (value: string) => void;
|
setQuery: (value: string) => void;
|
||||||
sort: GameSort;
|
sort: GameSort;
|
||||||
setSort: (value: GameSort) => void;
|
setSort: (value: GameSort) => void;
|
||||||
gameDir: string;
|
|
||||||
onPickDirectory: () => void;
|
|
||||||
kebabItems: ReadonlyArray<KebabItem>;
|
kebabItems: ReadonlyArray<KebabItem>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,16 +28,25 @@ export const TopBar = ({
|
|||||||
setQuery,
|
setQuery,
|
||||||
sort,
|
sort,
|
||||||
setSort,
|
setSort,
|
||||||
gameDir,
|
|
||||||
onPickDirectory,
|
|
||||||
kebabItems,
|
kebabItems,
|
||||||
}: Props) => (
|
}: Props) => (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
|
<div className="topbar-left">
|
||||||
<Brand peerCount={peerCount} />
|
<Brand peerCount={peerCount} />
|
||||||
|
<div className="topbar-left-trail">
|
||||||
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
|
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-center">
|
||||||
<SearchField value={query} onChange={setQuery} />
|
<SearchField value={query} onChange={setQuery} />
|
||||||
|
</div>
|
||||||
|
<div className="topbar-right">
|
||||||
|
<div className="topbar-right-lead">
|
||||||
<SortMenu value={sort} onChange={setSort} />
|
<SortMenu value={sort} onChange={setSort} />
|
||||||
<DirectoryButton path={gameDir} onClick={onPickDirectory} />
|
</div>
|
||||||
|
<div className="topbar-right-tail">
|
||||||
<KebabMenu items={kebabItems} />
|
<KebabMenu items={kebabItems} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export const useGameActions = (
|
|||||||
|
|
||||||
const install = useCallback(async (id: string) => {
|
const install = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await invoke<boolean>('install_game', { id });
|
const success = await invoke<boolean>('install_game', {
|
||||||
|
id,
|
||||||
|
username: settings.username,
|
||||||
|
});
|
||||||
if (!success) return;
|
if (!success) return;
|
||||||
|
|
||||||
const game = games.games.find(item => item.id === id);
|
const game = games.games.find(item => item.id === id);
|
||||||
@@ -61,16 +64,19 @@ export const useGameActions = (
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('install_game failed:', err);
|
console.error('install_game failed:', err);
|
||||||
}
|
}
|
||||||
}, [games]);
|
}, [games, settings.username]);
|
||||||
|
|
||||||
const update = useCallback(async (id: string) => {
|
const update = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await invoke<boolean>('update_game', { id });
|
const success = await invoke<boolean>('update_game', {
|
||||||
|
id,
|
||||||
|
username: settings.username,
|
||||||
|
});
|
||||||
if (success) games.markChecking(id);
|
if (success) games.markChecking(id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('update_game failed:', err);
|
console.error('update_game failed:', err);
|
||||||
}
|
}
|
||||||
}, [games]);
|
}, [games, settings.username]);
|
||||||
|
|
||||||
const uninstall = useCallback(async (id: string) => {
|
const uninstall = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store
|
|||||||
*/
|
*/
|
||||||
export const useGameDirectory = () => {
|
export const useGameDirectory = () => {
|
||||||
const [gameDir, setGameDir] = useState('');
|
const [gameDir, setGameDir] = useState('');
|
||||||
|
const [gameDirExists, setGameDirExists] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -30,7 +31,11 @@ export const useGameDirectory = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameDir) return;
|
if (!gameDir.trim()) {
|
||||||
|
setGameDirExists(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
const sync = async () => {
|
const sync = async () => {
|
||||||
try {
|
try {
|
||||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
@@ -38,19 +43,50 @@ export const useGameDirectory = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to persist game directory:', err);
|
console.error('Failed to persist game directory:', err);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
void sync();
|
let exists = false;
|
||||||
|
try {
|
||||||
|
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to validate game directory:', err);
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
setGameDirExists(exists);
|
||||||
|
if (!exists) return;
|
||||||
|
|
||||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||||
console.error('Failed to push game directory to backend:', err),
|
console.error('Failed to push game directory to backend:', err),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
void sync();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [gameDir]);
|
}, [gameDir]);
|
||||||
|
|
||||||
|
const hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
|
||||||
|
|
||||||
const rescan = useCallback(() => {
|
const rescan = useCallback(() => {
|
||||||
if (!gameDir) return;
|
if (!gameDir.trim()) {
|
||||||
|
setGameDirExists(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sync = async () => {
|
||||||
|
let exists = false;
|
||||||
|
try {
|
||||||
|
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to validate game directory:', err);
|
||||||
|
}
|
||||||
|
setGameDirExists(exists);
|
||||||
|
if (!exists) return;
|
||||||
|
|
||||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||||
console.error('Failed to rescan game directory:', err),
|
console.error('Failed to rescan game directory:', err),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
void sync();
|
||||||
}, [gameDir]);
|
}, [gameDir]);
|
||||||
|
|
||||||
return { gameDir, setGameDir, rescan };
|
return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ export const formatEtiVersion = (raw: string | undefined): string => {
|
|||||||
return raw;
|
return raw;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Truncate a path with a leading ellipsis when it exceeds the limit. */
|
|
||||||
export const truncatePath = (path: string, max = 36): string =>
|
|
||||||
path.length > max ? `…${path.slice(-(max - 1))}` : path;
|
|
||||||
|
|
||||||
export const formatPlayers = (max?: number): string => {
|
export const formatPlayers = (max?: number): string => {
|
||||||
if (!max || max <= 0) return '—';
|
if (!max || max <= 0) return '—';
|
||||||
return max === 1 ? '1' : `1–${max}`;
|
return max === 1 ? '1' : `1–${max}`;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-0);
|
background: var(--bg-0);
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: launcher;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
@@ -56,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top bar */
|
/* Top bar — three visual zones with search at the geometric center */
|
||||||
.topbar {
|
.topbar {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -64,12 +66,75 @@
|
|||||||
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||||
backdrop-filter: blur(20px) saturate(140%);
|
backdrop-filter: blur(20px) saturate(140%);
|
||||||
border-bottom: 1px solid var(--bd-1);
|
border-bottom: 1px solid var(--bd-1);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 16px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
.topbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
justify-content: space-between;
|
||||||
padding: 14px 24px;
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-left-trail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-center .search {
|
||||||
|
flex: 0 1 360px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-right-lead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-right-tail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below ~1100px of launcher width the geometric centering stops reading —
|
||||||
|
collapse the three zones into a single left-to-right flowing row. */
|
||||||
|
@container launcher (max-width: 1100px) {
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
min-height: 64px;
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.topbar-left,
|
||||||
|
.topbar-center,
|
||||||
|
.topbar-right {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.topbar-center {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
.topbar-center .search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand */
|
/* Brand */
|
||||||
@@ -239,6 +304,10 @@
|
|||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
.search-clear.is-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.search-kbd {
|
.search-kbd {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -325,46 +394,6 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Directory button */
|
|
||||||
.dirbtn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 12px;
|
|
||||||
background: var(--bg-2);
|
|
||||||
border: 1px solid var(--bd-1);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--t-2);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
max-width: 360px;
|
|
||||||
transition:
|
|
||||||
border-color 0.15s,
|
|
||||||
color 0.15s;
|
|
||||||
flex-shrink: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.dirbtn:hover {
|
|
||||||
border-color: var(--bd-2);
|
|
||||||
color: var(--t-1);
|
|
||||||
}
|
|
||||||
.dirbtn-label {
|
|
||||||
color: var(--t-1);
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.dirbtn-path {
|
|
||||||
color: var(--t-3);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11.5px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Kebab menu */
|
/* Kebab menu */
|
||||||
.kebab {
|
.kebab {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1468,11 +1497,18 @@
|
|||||||
}
|
}
|
||||||
.settings-foot {
|
.settings-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 14px 22px 18px;
|
padding: 14px 22px 18px;
|
||||||
border-top: 1px solid var(--bd-1);
|
border-top: 1px solid var(--bd-1);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.settings-build-nr {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--t-3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.settings-done {
|
.settings-done {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 22px;
|
padding: 0 22px;
|
||||||
@@ -1526,6 +1562,86 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings: game-folder field */
|
||||||
|
.folder-field {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 340px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 4px 0 12px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
}
|
||||||
|
.folder-field:hover {
|
||||||
|
border-color: var(--bd-2);
|
||||||
|
}
|
||||||
|
.folder-field.is-unset {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
|
||||||
|
background: color-mix(in srgb, var(--danger) 6%, var(--bg-3));
|
||||||
|
}
|
||||||
|
.folder-field.is-unset:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
|
||||||
|
}
|
||||||
|
.folder-field-icon {
|
||||||
|
color: var(--t-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.folder-field.is-unset .folder-field-icon {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
.folder-field-path {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
direction: rtl;
|
||||||
|
text-align: left;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
}
|
||||||
|
.folder-field-empty {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f87171;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.folder-field-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
.folder-field-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
.folder-field.is-unset .folder-field-btn {
|
||||||
|
background: color-mix(in srgb, var(--accent) 85%, transparent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.folder-field.is-unset .folder-field-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Settings: color swatches */
|
/* Settings: color swatches */
|
||||||
.swatch-row {
|
.swatch-row {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1639,6 +1755,9 @@
|
|||||||
margin: 0 0 20px;
|
margin: 0 0 20px;
|
||||||
max-width: 44ch;
|
max-width: 44ch;
|
||||||
}
|
}
|
||||||
|
.empty-state-title + .ghost-btn {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
.empty-state .ghost-btn {
|
.empty-state .ghost-btn {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_LANSPREAD_BUILD_NR: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const openLogsWindow = async () => {
|
|||||||
|
|
||||||
export const MainWindow = () => {
|
export const MainWindow = () => {
|
||||||
const { settings, set: setSetting } = useSettings();
|
const { settings, set: setSetting } = useSettings();
|
||||||
const { gameDir, setGameDir, rescan } = useGameDirectory();
|
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
|
||||||
const games = useGames(rescan);
|
const games = useGames(rescan);
|
||||||
const actions = useGameActions(games, settings);
|
const actions = useGameActions(games, settings);
|
||||||
const thumbnails = useThumbnails();
|
const thumbnails = useThumbnails();
|
||||||
@@ -53,14 +53,17 @@ export const MainWindow = () => {
|
|||||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const visibleGames = useMemo(
|
||||||
const counts = useMemo(() => countByFilter(games.games), [games.games]);
|
() => hasGameDirectory ? games.games : [],
|
||||||
|
[games.games, hasGameDirectory],
|
||||||
|
);
|
||||||
|
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
|
||||||
|
|
||||||
// Query is local UI state (no need to persist).
|
// Query is local UI state (no need to persist).
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const filteredGames = useMemo(
|
const filteredGames = useMemo(
|
||||||
() => applyFilterAndSort(games.games, settings.filter, settings.sort, query),
|
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
|
||||||
[games.games, settings.filter, settings.sort, query],
|
[visibleGames, settings.filter, settings.sort, query],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openGame = useMemo<Game | null>(
|
const openGame = useMemo<Game | null>(
|
||||||
@@ -116,8 +119,6 @@ export const MainWindow = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={rootStyle}>
|
<div className={className} style={rootStyle}>
|
||||||
{gameDir ? (
|
|
||||||
<>
|
|
||||||
<TopBar
|
<TopBar
|
||||||
peerCount={games.totalPeerCount}
|
peerCount={games.totalPeerCount}
|
||||||
filter={settings.filter}
|
filter={settings.filter}
|
||||||
@@ -127,14 +128,14 @@ export const MainWindow = () => {
|
|||||||
setQuery={setQuery}
|
setQuery={setQuery}
|
||||||
sort={settings.sort}
|
sort={settings.sort}
|
||||||
setSort={(v) => setSetting('sort', v)}
|
setSort={(v) => setSetting('sort', v)}
|
||||||
gameDir={gameDir}
|
|
||||||
onPickDirectory={() => void pickDirectory()}
|
|
||||||
kebabItems={kebabItems}
|
kebabItems={kebabItems}
|
||||||
/>
|
/>
|
||||||
<main className="grid-wrap">
|
<main className="grid-wrap">
|
||||||
|
{hasGameDirectory ? (
|
||||||
|
<>
|
||||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||||
{filteredGames.length === 0 ? (
|
{filteredGames.length === 0 ? (
|
||||||
games.games.length === 0 ? (
|
visibleGames.length === 0 ? (
|
||||||
<EmptyResultsState
|
<EmptyResultsState
|
||||||
title="Scanning for games"
|
title="Scanning for games"
|
||||||
hint="Looking for game bundles in your selected directory…"
|
hint="Looking for game bundles in your selected directory…"
|
||||||
@@ -155,13 +156,11 @@ export const MainWindow = () => {
|
|||||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<main className="grid-wrap">
|
|
||||||
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
|
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
|
||||||
</main>
|
|
||||||
)}
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
{openGame && (
|
{openGame && (
|
||||||
<GameDetailModal
|
<GameDetailModal
|
||||||
@@ -188,6 +187,9 @@ export const MainWindow = () => {
|
|||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
gameDir={gameDir}
|
||||||
|
hasGameDirectory={hasGameDirectory}
|
||||||
|
onPickDirectory={() => void pickDirectory()}
|
||||||
onChange={setSetting}
|
onChange={setSetting}
|
||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// A timestamp keeps build numbers monotonic without a checked-in counter file.
|
||||||
|
const buildNr = Date.now().toString();
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
// @ts-expect-error process is a nodejs global
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
"import.meta.env.VITE_LANSPREAD_BUILD_NR": JSON.stringify(buildNr),
|
||||||
|
},
|
||||||
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
|
|||||||
+80
-13
@@ -32,6 +32,21 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Changes since v3
|
||||||
|
|
||||||
|
- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
|
||||||
|
- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below.
|
||||||
|
- **Persisted setting renamed.** `gameFolderSet: boolean` → `gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans).
|
||||||
|
|
||||||
|
## Changes since v2
|
||||||
|
|
||||||
|
- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows):
|
||||||
|
- **Left:** brand mark + wordmark.
|
||||||
|
- **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right.
|
||||||
|
- **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes).
|
||||||
|
- Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet.
|
||||||
|
- See "Top bar (variant A)" below for the full spec and rationale.
|
||||||
|
|
||||||
## Changes since v1
|
## Changes since v1
|
||||||
|
|
||||||
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
|
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
|
||||||
@@ -48,10 +63,11 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
|
|||||||
|
|
||||||
**Layout (top-to-bottom):**
|
**Layout (top-to-bottom):**
|
||||||
|
|
||||||
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Contents, left-to-right with 18px gap and 24px horizontal padding:
|
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other.
|
||||||
|
|
||||||
- **Brand** — 28×28px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20px white. Next to it, the wordmark "SoftLAN" in 15px / 700 weight `--t-1` `#e6edf3`.
|
- **Left zone (col 1, flex space-between):**
|
||||||
- **Segmented filter pills** — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
|
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`.
|
||||||
|
- **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
|
||||||
- `All Games` · count chip
|
- `All Games` · count chip
|
||||||
- `Local` · count chip
|
- `Local` · count chip
|
||||||
- `Installed` · count chip
|
- `Installed` · count chip
|
||||||
@@ -59,10 +75,17 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
|
|||||||
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
|
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
|
||||||
|
|
||||||
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
|
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
|
||||||
- **Search field** — 36px tall, min-width 320px (flex 0 1 380px). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Has a leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border becomes `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The "/" key shortcut should focus the search.
|
|
||||||
- **Sort menu** — 36px button, same surface style as search. Label `Sort: <bold value>` plus 13px sort-bars icon and 11px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`.
|
The filter is grouped semantically with the search — it scopes what the user is searching, so it belongs at the search field's left shoulder.
|
||||||
- **Game directory button** — 36px button, max-width 360px. Folder icon, "Game directory" label (600 weight `--t-1`), then the current path in `ui-monospace` 11.5px `--t-3` truncated with leading ellipsis when long (e.g. `…s/Desktop/eti_games_AFTER_LAN_2025`).
|
|
||||||
- **Kebab menu** (`⋮`) — 36×36 button with same surface. Menu items: `Settings` (opens Settings dialog — see below), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`.
|
- **Center zone (col 2, search alone):**
|
||||||
|
- **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so it can't elbow into the side zones). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The `/` key shortcut should focus the search.
|
||||||
|
|
||||||
|
- **Right zone (col 3, flex space-between with two sub-groups):**
|
||||||
|
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
|
||||||
|
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
|
||||||
|
|
||||||
|
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
|
||||||
|
|
||||||
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:
|
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:
|
||||||
- Left: `Showing <strong>N</strong> of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`).
|
- Left: `Showing <strong>N</strong> of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`).
|
||||||
@@ -152,6 +175,11 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
|
|||||||
│ │
|
│ │
|
||||||
│ LIBRARY │
|
│ LIBRARY │
|
||||||
│ │
|
│ │
|
||||||
|
│ Game folder │
|
||||||
|
│ Parent directory where games are │
|
||||||
|
│ downloaded and installed │
|
||||||
|
│ [📁 /home/pfs/…/eti_games [Change…]] │ ← folder field (340×36)
|
||||||
|
│ │
|
||||||
│ Grid density │
|
│ Grid density │
|
||||||
│ How tightly cards are packed │
|
│ How tightly cards are packed │
|
||||||
│ [Compact│Normal│Large]│
|
│ [Compact│Normal│Large]│
|
||||||
@@ -175,6 +203,10 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
|
|||||||
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
|
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
|
||||||
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
|
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
|
||||||
|
|
||||||
|
**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**.
|
||||||
|
|
||||||
|
- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference.
|
||||||
|
|
||||||
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
|
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
|
||||||
|
|
||||||
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
|
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
|
||||||
@@ -190,6 +222,40 @@ Persisted settings (write through to local storage / Tauri config):
|
|||||||
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
|
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
|
||||||
- `density`: `compact` | `normal` | `large`. Default `normal`.
|
- `density`: `compact` | `normal` | `large`. Default `normal`.
|
||||||
- `aspect`: `box` | `square` | `banner`. Default `box`.
|
- `aspect`: `box` | `square` | `banner`. Default `box`.
|
||||||
|
- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Game-folder field
|
||||||
|
|
||||||
|
A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live).
|
||||||
|
|
||||||
|
**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs.
|
||||||
|
|
||||||
|
Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory:
|
||||||
|
|
||||||
|
| State | Trigger | Path display | Border | Button label |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) |
|
||||||
|
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) |
|
||||||
|
|
||||||
|
"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple.
|
||||||
|
|
||||||
|
**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right:
|
||||||
|
|
||||||
|
1. **Folder icon** — `Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state).
|
||||||
|
2. **Path display** — `flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead.
|
||||||
|
3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library.
|
||||||
|
|
||||||
|
**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps).
|
||||||
|
|
||||||
|
**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated.
|
||||||
|
|
||||||
|
**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring.
|
||||||
|
|
||||||
|
**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing.
|
||||||
|
|
||||||
|
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -371,8 +437,8 @@ Implement only if you decide variant A doesn't work after building.
|
|||||||
- **Click filter tab / segmented pill** → change filter.
|
- **Click filter tab / segmented pill** → change filter.
|
||||||
- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
|
- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
|
||||||
- **Hover game card** → lift + accent border glow + cover image scale 1.03.
|
- **Hover game card** → lift + accent border glow + cover image scale 1.03.
|
||||||
- **Click "Game directory" button** → open native folder picker via Tauri; on selection, rescan library.
|
|
||||||
- **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
|
- **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
|
||||||
|
- **Click "Change…" / "Choose…" in the Settings → Library → Game folder row** → open native folder picker via Tauri; on selection, write to `settings.gameFolder` and rescan library. The field indicates whether a valid folder is currently configured (mono path + neutral `Change…`) or not (red `Not set` + accent-filled `Choose…`) — see "Game-folder field" above.
|
||||||
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
|
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
|
||||||
- **Click "Refresh library"** in kebab → re-runs the library scan.
|
- **Click "Refresh library"** in kebab → re-runs the library scan.
|
||||||
- **Esc** → closes any open modal (detail overlay, Settings).
|
- **Esc** → closes any open modal (detail overlay, Settings).
|
||||||
@@ -426,12 +492,13 @@ type LauncherUI = {
|
|||||||
**Persisted settings** (mirror of Settings dialog state):
|
**Persisted settings** (mirror of Settings dialog state):
|
||||||
```ts
|
```ts
|
||||||
type LauncherSettings = {
|
type LauncherSettings = {
|
||||||
username: string; // new
|
username: string;
|
||||||
language: 'en' | 'de'; // new
|
language: 'en' | 'de';
|
||||||
accent: string; // hex from the curated 6-color palette
|
accent: string; // hex from the curated 6-color palette
|
||||||
bg: 'flat' | 'gradient' | 'animated';
|
bg: 'flat' | 'gradient' | 'animated';
|
||||||
density: 'compact' | 'normal' | 'large';
|
density: 'compact' | 'normal' | 'large';
|
||||||
aspect: 'box' | 'square' | 'banner';
|
aspect: 'box' | 'square' | 'banner';
|
||||||
|
gameFolder: string | null; // v3: moved out of top bar, persists actual path
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -523,8 +590,8 @@ design_reference/
|
|||||||
├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock
|
├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock
|
||||||
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
|
├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard,
|
||||||
│ SegmentedFilters, UnderlineFilters, SearchField,
|
│ SegmentedFilters, UnderlineFilters, SearchField,
|
||||||
│ SortMenu, StorageMeter, DirectoryButton, KebabMenu,
|
│ SortMenu, StorageMeter, KebabMenu,
|
||||||
│ GameDetailModal, SettingsDialog
|
│ GameDetailModal, SettingsDialog (incl. GameFolderField)
|
||||||
└── launcher.jsx ← <Launcher> component composing chrome + grid + modals
|
└── launcher.jsx ← <Launcher> component composing chrome + grid + modals
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -536,7 +603,7 @@ To preview the design in a browser:
|
|||||||
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
|
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
|
||||||
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
|
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
|
||||||
- **F** — Settings dialog open, with the new **Profile** section at the top
|
- **F** — Settings dialog open, with the new **Profile** section at the top
|
||||||
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect). In the production app these live in the Settings dialog.
|
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect / game folder). In the production app these all live in the Settings dialog.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|||||||
"density": "normal",
|
"density": "normal",
|
||||||
"aspect": "square",
|
"aspect": "square",
|
||||||
"bg": "gradient",
|
"bg": "gradient",
|
||||||
"username": "d",
|
"username": "ddidderr",
|
||||||
"language": "en"
|
"language": "en",
|
||||||
|
"gameFolder": "\/some\/folder\/to\/games"
|
||||||
}/*EDITMODE-END*/;
|
}/*EDITMODE-END*/;
|
||||||
|
|
||||||
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
|
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
|
||||||
@@ -113,6 +114,10 @@ function App() {
|
|||||||
<TweakRadio label="Cover aspect" value={t.aspect}
|
<TweakRadio label="Cover aspect" value={t.aspect}
|
||||||
options={['box', 'square', 'banner']}
|
options={['box', 'square', 'banner']}
|
||||||
onChange={(v) => setTweak('aspect', v)}/>
|
onChange={(v) => setTweak('aspect', v)}/>
|
||||||
|
|
||||||
|
<TweakSection label="Library"/>
|
||||||
|
<TweakText label="Game folder" value={t.gameFolder}
|
||||||
|
onChange={(v) => setTweak('gameFolder', v)}/>
|
||||||
</TweaksPanel>
|
</TweaksPanel>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -354,12 +354,17 @@ function StorageMeter({ accent, compact = false }) {
|
|||||||
// Directory button (shows path)
|
// Directory button (shows path)
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
function DirectoryButton({ path }) {
|
function DirectoryButton({ path }) {
|
||||||
const short = path.length > 36 ? '…' + path.slice(-34) : path;
|
const isSet = !!(path && path.trim());
|
||||||
|
const label = isSet ? 'Game folder' : 'Set game folder';
|
||||||
|
const tooltip = isSet ? path : 'Please select a game folder';
|
||||||
return (
|
return (
|
||||||
<button className="dirbtn" title={path}>
|
<button
|
||||||
|
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`}
|
||||||
|
title={tooltip}
|
||||||
|
aria-label={isSet ? `Game folder: ${path}` : 'Set game folder'}
|
||||||
|
>
|
||||||
<Icon.folder/>
|
<Icon.folder/>
|
||||||
<span className="dirbtn-label">Game directory</span>
|
<span className="dirbtn-label">{label}</span>
|
||||||
<span className="dirbtn-path">{short}</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -519,6 +524,31 @@ function SegmentedRadio({ value, options, onChange, accent }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GameFolderField({ value, onChange, accent }) {
|
||||||
|
const isSet = !!(value && value.trim());
|
||||||
|
const handleChange = () => {
|
||||||
|
// In production: open native folder picker via Tauri.
|
||||||
|
// For the prototype, prompt for a path so the field is exercisable.
|
||||||
|
const next = window.prompt('Game folder path (leave empty to clear)', value || '');
|
||||||
|
if (next == null) return;
|
||||||
|
onChange(next.trim());
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={`folder-field ${isSet ? 'is-set' : 'is-unset'}`}
|
||||||
|
style={{ '--accent': accent }}>
|
||||||
|
<span className="folder-field-icon" aria-hidden="true"><Icon.folder/></span>
|
||||||
|
<div className="folder-field-path" title={isSet ? value : 'No folder selected'}>
|
||||||
|
{isSet
|
||||||
|
? <bdi>{value}</bdi>
|
||||||
|
: <span className="folder-field-empty">Not set</span>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="folder-field-btn" onClick={handleChange}>
|
||||||
|
{isSet ? 'Change\u2026' : 'Choose\u2026'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ColorSwatchPicker({ value, options, onChange }) {
|
function ColorSwatchPicker({ value, options, onChange }) {
|
||||||
return (
|
return (
|
||||||
<div className="swatch-row">
|
<div className="swatch-row">
|
||||||
@@ -577,6 +607,11 @@ function SettingsDialog({ settings, onChange, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<div className="settings-section-title">Library</div>
|
<div className="settings-section-title">Library</div>
|
||||||
|
<SettingsRow label="Game folder" hint="Parent directory where games are downloaded and installed">
|
||||||
|
<GameFolderField value={settings.gameFolder}
|
||||||
|
onChange={(v) => onChange('gameFolder', v)}
|
||||||
|
accent={settings.accent}/>
|
||||||
|
</SettingsRow>
|
||||||
<SettingsRow label="Grid density" hint="How tightly cards are packed">
|
<SettingsRow label="Grid density" hint="How tightly cards are packed">
|
||||||
<SegmentedRadio value={settings.density}
|
<SegmentedRadio value={settings.density}
|
||||||
options={SETTING_OPTIONS.density}
|
options={SETTING_OPTIONS.density}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// launcher.jsx — composes top bar + grid into a complete launcher screen
|
// launcher.jsx — composes top bar + grid into a complete launcher screen
|
||||||
// Comes in two chrome variants: 'single' (one-row) and 'two' (two-row).
|
// Comes in two chrome variants: 'single' (one-row) and 'two' (two-row).
|
||||||
|
|
||||||
const DIR_PATH = '/home/pfs/Desktop/eti_games_AFTER_LAN_2025';
|
|
||||||
|
|
||||||
function applyFilterAndSort(games, filter, sort, query) {
|
function applyFilterAndSort(games, filter, sort, query) {
|
||||||
let g = filterGames(games, filter);
|
let g = filterGames(games, filter);
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
@@ -51,15 +49,26 @@ function Launcher({
|
|||||||
style={{ '--accent': accent }}>
|
style={{ '--accent': accent }}>
|
||||||
{variant === 'single' ? (
|
{variant === 'single' ? (
|
||||||
<header className="topbar topbar-single">
|
<header className="topbar topbar-single">
|
||||||
|
<div className="topbar-left">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<div className="brand-mark" style={{ background: accent }}>S</div>
|
<div className="brand-mark" style={{ background: accent }}>S</div>
|
||||||
<div className="brand-name">SoftLAN</div>
|
<div className="brand-name">SoftLAN</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="topbar-left-trail">
|
||||||
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
|
<SegmentedFilters value={filter} onChange={setFilter} counts={counts} accent={accent}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-center">
|
||||||
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
|
<SearchField value={query} onChange={setQuery} accent={accent} wide/>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-right">
|
||||||
|
<div className="topbar-right-lead">
|
||||||
<SortMenu value={sort} onChange={setSort} accent={accent}/>
|
<SortMenu value={sort} onChange={setSort} accent={accent}/>
|
||||||
<DirectoryButton path={DIR_PATH}/>
|
</div>
|
||||||
|
<div className="topbar-right-trail">
|
||||||
<KebabMenu items={menuItems}/>
|
<KebabMenu items={menuItems}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
) : (
|
) : (
|
||||||
<header className="topbar topbar-two">
|
<header className="topbar topbar-two">
|
||||||
@@ -68,7 +77,6 @@ function Launcher({
|
|||||||
<div className="brand-mark" style={{ background: accent }}>S</div>
|
<div className="brand-mark" style={{ background: accent }}>S</div>
|
||||||
<div className="brand-name">SoftLAN <span className="brand-name-soft">Launcher</span></div>
|
<div className="brand-name">SoftLAN <span className="brand-name-soft">Launcher</span></div>
|
||||||
</div>
|
</div>
|
||||||
<DirectoryButton path={DIR_PATH}/>
|
|
||||||
<div className="topbar-row1-right">
|
<div className="topbar-row1-right">
|
||||||
<StorageMeter accent={accent}/>
|
<StorageMeter accent={accent}/>
|
||||||
<KebabMenu items={menuItems}/>
|
<KebabMenu items={menuItems}/>
|
||||||
|
|||||||
@@ -41,6 +41,8 @@
|
|||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: launcher;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -77,13 +79,70 @@
|
|||||||
border-bottom: 1px solid var(--bd-1);
|
border-bottom: 1px solid var(--bd-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Variant 1: single row */
|
/* Variant 1: single row — three visual zones with search at geometric center */
|
||||||
.topbar-single {
|
.topbar-single {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 16px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
}
|
||||||
|
.topbar-single .topbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
justify-content: space-between;
|
||||||
padding: 14px 24px;
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-single .topbar-left-trail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-single .topbar-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-single .topbar-center .search { flex: 0 1 360px; min-width: 0; }
|
||||||
|
.topbar-single .topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-single .topbar-right-lead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar-single .topbar-right-trail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When the launcher gets narrow, the three-zone centering breaks down —
|
||||||
|
collapse to a single left-to-right flowing row. */
|
||||||
|
@container launcher (max-width: 1100px) {
|
||||||
|
.topbar-single {
|
||||||
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.topbar-single .topbar-left,
|
||||||
|
.topbar-single .topbar-center,
|
||||||
|
.topbar-single .topbar-right,
|
||||||
|
.topbar-single .topbar-right-trail {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.topbar-single .topbar-center { flex: 1 1 200px; }
|
||||||
|
.topbar-single .topbar-center .search { flex: 1 1 auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Variant 2: two row */
|
/* Variant 2: two row */
|
||||||
@@ -307,29 +366,27 @@
|
|||||||
|
|
||||||
/* ─── Directory button ─── */
|
/* ─── Directory button ─── */
|
||||||
.dirbtn {
|
.dirbtn {
|
||||||
|
position: relative;
|
||||||
display: inline-flex; align-items: center; gap: 8px;
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
height: 36px; padding: 0 12px;
|
height: 36px; padding: 0 14px 0 12px;
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
border: 1px solid var(--bd-1);
|
border: 1px solid var(--bd-1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--t-2);
|
color: var(--t-1);
|
||||||
font: inherit; font-size: 12.5px;
|
font: inherit; font-size: 12.5px; font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
max-width: 360px;
|
transition: border-color .15s, color .15s, background .15s;
|
||||||
transition: border-color .15s, color .15s;
|
flex-shrink: 0;
|
||||||
flex-shrink: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.dirbtn:hover { border-color: var(--bd-2); color: var(--t-1); }
|
|
||||||
.dirbtn-label { color: var(--t-1); font-weight: 600; flex-shrink: 0; }
|
|
||||||
.dirbtn-path {
|
|
||||||
color: var(--t-3);
|
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
||||||
font-size: 11.5px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
}
|
||||||
text-overflow: ellipsis;
|
.dirbtn:hover { border-color: var(--bd-2); }
|
||||||
min-width: 0;
|
.dirbtn-label { line-height: 1; }
|
||||||
|
.dirbtn-unset {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
|
||||||
|
}
|
||||||
|
.dirbtn-unset:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Kebab menu ─── */
|
/* ─── Kebab menu ─── */
|
||||||
@@ -1183,3 +1240,74 @@
|
|||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Settings: game-folder field ─── */
|
||||||
|
.folder-field {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 340px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 4px 0 12px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--bd-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.folder-field:hover { border-color: var(--bd-2); }
|
||||||
|
.folder-field.is-unset {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
|
||||||
|
background: color-mix(in srgb, var(--danger) 6%, var(--bg-3));
|
||||||
|
}
|
||||||
|
.folder-field.is-unset:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
|
||||||
|
}
|
||||||
|
.folder-field-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--t-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.folder-field.is-unset .folder-field-icon { color: #f87171; }
|
||||||
|
.folder-field-path {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
direction: rtl; /* truncate from the head so the leaf folder stays visible */
|
||||||
|
text-align: left;
|
||||||
|
unicode-bidi: plaintext; /* keep character order intact */
|
||||||
|
}
|
||||||
|
.folder-field-empty {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f87171;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
.folder-field-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: var(--t-1);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.folder-field-btn:hover { background: rgba(255,255,255,0.12); }
|
||||||
|
.folder-field.is-unset .folder-field-btn {
|
||||||
|
background: color-mix(in srgb, var(--accent, #3b82f6) 85%, transparent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.folder-field.is-unset .folder-field-btn:hover {
|
||||||
|
background: var(--accent, #3b82f6);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user