feat(catalog): separate generated production and local test authority

The launcher previously defaulted to fixtures and production generation used
the source database directly. Generate a separate database and manifest set
from package directories, validating staged output with the application loader
before installation. Keep strict selection by default, with independent
opt-ins for missing games and package version overrides in the copied database.
Share database filtering and staging with the fixture publisher, and include
source metadata and generation modes in the publication cache.

Make normal runs consume existing production authority. Add an explicit local
test recipe with separate resources, app settings, and a compiled startup game
directory. Build-time gates exclude local authority from production and require
Tauri development mode. Document the generation and launch workflows.

Clear generated catalog copies before Tauri copies the selected resource tree
so mode switches and reduced catalogs cannot retain stale manifests. Watch the
copied files to repair deletion and preserve prior output mtimes only when the
bytes are unchanged, allowing subsequent builds to become fresh.

Test Plan:
- `just fmt` -- passed.
- `just clippy` -- passed with warnings denied.
- `just test` -- workspace tests passed using fixture authority.
- `just frontend-test` -- 94 passed.
- `python3 -m unittest discover -s tools -p 'test_catalog_source_cache.py'`
  -- 4 passed.
- `git diff --cached --check` -- passed.
- Interactive GUI launches and production bundles were not exercised.
This commit is contained in:
ddidderr committed 2026-09-12 19:41:35 +02:00
1 parent ab0c95b80f
commit 6b65a66465
21 files changed
+1777 -273

No files matched your search

@@ -0,0 +1,372 @@
#[path = "../build_support/catalog_resources.rs"]
mod catalog_resources;
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime},
};
use catalog_resources::{CatalogResources, reset_catalog_resources};
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"lanspread-catalog-resources-{}-{}",
std::process::id(),
NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir(&path).expect("test directory should be unique");
Self(path)
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).expect("test directory should be removed");
}
}
#[test]
fn resets_catalog_copies_for_native_and_cross_target_profiles() {
let root = TestDirectory::new();
for profile in ["target/release", "custom/x86_64-pc-windows-msvc/production"] {
let profile = root.0.join(profile);
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
fs::create_dir(profile.join("manifests"))
.expect("fixture filesystem operation should succeed");
fs::write(profile.join("manifests/fixture.json"), b"old fixture")
.expect("fixture filesystem operation should succeed");
fs::write(
profile.join("manifests/removed-game.json"),
b"old production",
)
.expect("fixture filesystem operation should succeed");
fs::write(profile.join("game.db"), b"old database")
.expect("fixture filesystem operation should succeed");
fs::write(profile.join("launcher"), b"binary")
.expect("fixture filesystem operation should succeed");
fs::write(out_dir.join("embedded-index"), b"new authority")
.expect("fixture filesystem operation should succeed");
let resources =
reset_catalog_resources(&out_dir).expect("fixture filesystem operation should succeed");
assert_eq!(
resources,
[profile.join("game.db"), profile.join("manifests")]
);
assert!(resources.iter().all(|path| !path.exists()));
assert_eq!(
fs::read(profile.join("launcher"))
.expect("fixture filesystem operation should succeed"),
b"binary"
);
assert_eq!(
fs::read(out_dir.join("embedded-index"))
.expect("fixture filesystem operation should succeed"),
b"new authority"
);
// Missing copies are a normal input after manual cleanup or a clean build.
assert_eq!(
reset_catalog_resources(&out_dir).expect("fixture filesystem operation should succeed"),
resources
);
}
}
#[test]
fn rejects_non_cargo_layout_before_removing_anything() {
let root = TestDirectory::new();
fs::write(root.0.join("game.db"), b"keep")
.expect("fixture filesystem operation should succeed");
assert!(reset_catalog_resources(&root.0.join("unexpected/launcher/out")).is_err());
assert!(reset_catalog_resources(Path::new("out")).is_err());
assert!(reset_catalog_resources(Path::new("target/release/build/launcher/out")).is_err());
assert_eq!(
fs::read(root.0.join("game.db")).expect("fixture filesystem operation should succeed"),
b"keep"
);
}
#[cfg(unix)]
#[test]
fn rejects_linked_manifest_destination_without_traversing_it() {
let root = TestDirectory::new();
let profile = root.0.join("target/debug");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
let source = root.0.join("source-manifests");
fs::create_dir(&source).expect("fixture filesystem operation should succeed");
fs::write(source.join("keep.json"), b"authority")
.expect("fixture filesystem operation should succeed");
std::os::unix::fs::symlink(&source, profile.join("manifests"))
.expect("fixture filesystem operation should succeed");
assert!(reset_catalog_resources(&out_dir).is_err());
assert_eq!(
fs::read(source.join("keep.json")).expect("fixture filesystem operation should succeed"),
b"authority"
);
}
fn timestamp(offset: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_000 + offset)
}
fn set_modified(path: &Path, modified: SystemTime) {
fs::OpenOptions::new()
.write(true)
.open(path)
.expect("fixture filesystem operation should succeed")
.set_modified(modified)
.expect("fixture filesystem operation should succeed");
}
fn write_catalog(root: &Path, database: &str, manifests: &[(&str, &str)]) {
fs::create_dir_all(root.join("manifests"))
.expect("fixture filesystem operation should succeed");
fs::write(root.join("game.db"), database).expect("fixture filesystem operation should succeed");
for (name, body) in manifests {
fs::write(root.join("manifests").join(name), body)
.expect("fixture filesystem operation should succeed");
}
for path in catalog_files(root) {
// Sources can be older than every build using them. Their timestamps
// must never replace the timestamps of changed destination bytes.
set_modified(&path, timestamp(0));
}
}
fn catalog_files(root: &Path) -> Vec<PathBuf> {
let mut paths = vec![root.join("game.db")];
paths.extend(
fs::read_dir(root.join("manifests"))
.expect("fixture filesystem operation should succeed")
.map(|entry| {
entry
.expect("manifest directory entry should be readable")
.path()
}),
);
paths.sort();
paths
}
fn simulate_tauri_copy(source: &Path, destination: &Path, modified: SystemTime) {
fs::create_dir_all(destination.join("manifests"))
.expect("fixture filesystem operation should succeed");
for path in catalog_files(source) {
let destination = destination.join(
path.strip_prefix(source)
.expect("catalog path should be below its source root"),
);
fs::copy(path, &destination).expect("fixture filesystem operation should succeed");
set_modified(&destination, modified);
}
}
fn assert_catalog_matches(source: &Path, destination: &Path, watched: &[PathBuf]) {
let expected = catalog_files(source)
.iter()
.map(|path| {
destination.join(
path.strip_prefix(source)
.expect("catalog path should be below its source root"),
)
})
.collect::<Vec<_>>();
assert_eq!(watched, expected);
assert_eq!(catalog_files(destination), expected);
for path in watched {
let source = source.join(
path.strip_prefix(destination)
.expect("copied path should be below its destination root"),
);
assert_eq!(
fs::read(path).expect("fixture filesystem operation should succeed"),
fs::read(source).expect("fixture filesystem operation should succeed")
);
}
}
#[test]
fn unchanged_copies_keep_previous_output_timestamps() {
let root = TestDirectory::new();
let source = root.0.join("source");
let profile = root.0.join("target/release");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
write_catalog(&source, "database", &[("game.json", "manifest")]);
simulate_tauri_copy(&source, &profile, timestamp(10));
let resources =
CatalogResources::prepare(&out_dir, &source).expect("catalog resources should prepare");
simulate_tauri_copy(&source, &profile, timestamp(20));
let watched = resources.finish().expect("copied resources should finish");
assert_catalog_matches(&source, &profile, &watched);
for path in watched {
assert_eq!(
fs::metadata(path)
.expect("fixture filesystem operation should succeed")
.modified()
.expect("fixture filesystem operation should succeed"),
timestamp(10)
);
}
}
#[test]
fn changed_copies_keep_new_timestamps_then_stabilize_on_the_next_build() {
let root = TestDirectory::new();
let source = root.0.join("source");
let profile = root.0.join("target/release");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
write_catalog(
&source,
"old database",
&[("game.json", "old manifest"), ("shared.json", "unchanged")],
);
simulate_tauri_copy(&source, &profile, timestamp(10));
// Equal lengths ensure that changed bytes, rather than just file sizes,
// distinguish a different catalog occupying the shared profile directory.
write_catalog(
&source,
"new database",
&[("game.json", "new manifest"), ("shared.json", "unchanged")],
);
for copied_at in [timestamp(20), timestamp(30)] {
let resources =
CatalogResources::prepare(&out_dir, &source).expect("catalog resources should prepare");
simulate_tauri_copy(&source, &profile, copied_at);
let watched = resources.finish().expect("copied resources should finish");
assert_catalog_matches(&source, &profile, &watched);
for path in watched {
let expected = if path.ends_with("shared.json") {
timestamp(10)
} else {
timestamp(20)
};
assert_eq!(
fs::metadata(path)
.expect("fixture filesystem operation should succeed")
.modified()
.expect("fixture filesystem operation should succeed"),
expected
);
}
}
}
#[test]
fn missing_database_manifest_or_manifest_directory_is_repaired_and_watched() {
let root = TestDirectory::new();
let source = root.0.join("source");
let profile = root.0.join("target/release");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
write_catalog(&source, "database", &[("game.json", "manifest")]);
for missing in ["game.db", "manifests/game.json", "manifests"] {
simulate_tauri_copy(&source, &profile, timestamp(10));
let missing_path = profile.join(missing);
if missing_path.is_dir() {
fs::remove_dir_all(&missing_path).expect("fixture filesystem operation should succeed");
} else {
fs::remove_file(&missing_path).expect("fixture filesystem operation should succeed");
}
let resources =
CatalogResources::prepare(&out_dir, &source).expect("catalog resources should prepare");
simulate_tauri_copy(&source, &profile, timestamp(20));
let watched = resources.finish().expect("copied resources should finish");
assert_catalog_matches(&source, &profile, &watched);
for path in watched {
let expected = if path.starts_with(&missing_path) {
timestamp(20)
} else {
timestamp(10)
};
assert_eq!(
fs::metadata(path)
.expect("fixture filesystem operation should succeed")
.modified()
.expect("fixture filesystem operation should succeed"),
expected
);
}
}
}
#[test]
fn mode_switch_and_catalog_shrink_remove_previous_manifests() {
let root = TestDirectory::new();
let fixture = root.0.join("fixture");
let production = root.0.join("production");
let profile = root.0.join("target/release");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
write_catalog(
&fixture,
"fixture database",
&[
("fixture.json", "fixture only"),
("shared.json", "fixture game"),
],
);
write_catalog(
&production,
"production database",
&[
("production.json", "production only"),
("shared.json", "real game"),
],
);
simulate_tauri_copy(&fixture, &profile, timestamp(10));
let resources = CatalogResources::prepare(&out_dir, &production)
.expect("production resources should prepare");
simulate_tauri_copy(&production, &profile, timestamp(20));
assert_catalog_matches(
&production,
&profile,
&resources.finish().expect("copied resources should finish"),
);
assert!(!profile.join("manifests/fixture.json").exists());
fs::remove_file(production.join("manifests/production.json"))
.expect("fixture filesystem operation should succeed");
write_catalog(
&production,
"smaller database",
&[("shared.json", "real game")],
);
let resources = CatalogResources::prepare(&out_dir, &production)
.expect("production resources should prepare");
simulate_tauri_copy(&production, &profile, timestamp(30));
assert_catalog_matches(
&production,
&profile,
&resources.finish().expect("copied resources should finish"),
);
assert!(!profile.join("manifests/production.json").exists());
let resources =
CatalogResources::prepare(&out_dir, &fixture).expect("fixture resources should prepare");
simulate_tauri_copy(&fixture, &profile, timestamp(40));
assert_catalog_matches(
&fixture,
&profile,
&resources.finish().expect("copied resources should finish"),
);
}