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

@@ -5,7 +5,17 @@ use std::{
use lanspread_compat::catalog_bundle::load_catalog_bundle;
const PRODUCTION_RESOURCES: [&str; 3] = ["assets/*", "game.db", "manifests/*"];
const PRODUCTION_RESOURCES: [(&str, &str); 3] = [
("assets/*", "assets/"),
("production-catalog/game.db", "game.db"),
("production-catalog/manifests/", "manifests/"),
];
const SOURCE_PRODUCTION_RESOURCES: [&str; 3] = ["assets/*", "game.db", "manifests/*"];
const LOCAL_RESOURCES: [(&str, &str); 3] = [
("assets/*", "assets/"),
("local-catalog/game.db", "game.db"),
("local-catalog/manifests/", "manifests/"),
];
const DEVELOPMENT_RESOURCES: [(&str, &str); 3] = [
(
"../../lanspread-peer-cli/catalogs/default/game.db",
@@ -21,6 +31,7 @@ const DEVELOPMENT_RESOURCES: [(&str, &str); 3] = [
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CatalogBuildMode {
FixtureDevelopment,
LocalDevelopment,
Production,
}
@@ -29,6 +40,7 @@ pub(crate) struct CatalogGateInput<'a> {
pub(crate) base_config: &'a str,
pub(crate) config_override: Option<&'a str>,
pub(crate) fixture_development_opt_in: bool,
pub(crate) local_development_opt_in: bool,
pub(crate) cargo_profile: Option<&'a str>,
pub(crate) out_dir: Option<&'a Path>,
}
@@ -51,6 +63,21 @@ pub(crate) fn select_catalog_build_mode(
.any(|component| component.as_os_str() == "production")
});
if input.local_development_opt_in {
if input.fixture_development_opt_in || forced_production {
return Err(
"local catalog testing cannot be combined with fixture or production builds"
.to_owned(),
);
}
if resources != ResourceAuthority::LocalDevelopment {
return Err(
"local catalog opt-in requires the exact local development resource map".to_owned(),
);
}
return Ok(CatalogBuildMode::LocalDevelopment);
}
if input.fixture_development_opt_in && !forced_production {
if resources != ResourceAuthority::FixtureDevelopment {
return Err(
@@ -102,6 +129,7 @@ pub(crate) fn validate_production_catalog(
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ResourceAuthority {
FixtureDevelopment,
LocalDevelopment,
Production,
}
@@ -137,8 +165,10 @@ fn classify_resources(resources: &serde_json::Value) -> Result<ResourceAuthority
})
.collect::<Result<Vec<_>, _>>()?;
let unique = actual.iter().copied().collect::<BTreeSet<_>>();
let expected = PRODUCTION_RESOURCES.into_iter().collect::<BTreeSet<_>>();
if actual.len() == PRODUCTION_RESOURCES.len() && unique == expected {
let expected = SOURCE_PRODUCTION_RESOURCES
.into_iter()
.collect::<BTreeSet<_>>();
if actual.len() == SOURCE_PRODUCTION_RESOURCES.len() && unique == expected {
return Ok(ResourceAuthority::Production);
}
return Err(format!(
@@ -156,18 +186,26 @@ fn classify_resources(resources: &serde_json::Value) -> Result<ResourceAuthority
.ok_or_else(|| "development resource destinations must be strings".to_owned())
})
.collect::<Result<BTreeMap<_, _>, _>>()?;
let expected = DEVELOPMENT_RESOURCES
let production = PRODUCTION_RESOURCES.into_iter().collect::<BTreeMap<_, _>>();
if actual == production {
return Ok(ResourceAuthority::Production);
}
let local = LOCAL_RESOURCES.into_iter().collect::<BTreeMap<_, _>>();
if actual == local {
return Ok(ResourceAuthority::LocalDevelopment);
}
let development = DEVELOPMENT_RESOURCES
.into_iter()
.collect::<BTreeMap<_, _>>();
if actual == expected {
if actual == development {
return Ok(ResourceAuthority::FixtureDevelopment);
}
return Err(format!(
"development resource map must be exactly {expected:?}, got {actual:?}"
"resource map must be exactly production {production:?}, fixture {development:?}, or local {local:?}, got {actual:?}"
));
}
Err("bundle.resources must be an exact production list or development map".to_owned())
Err("bundle.resources must be an exact production list or catalog resource map".to_owned())
}
#[cfg(test)]
@@ -186,7 +224,11 @@ mod tests {
static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const PRODUCTION: &str = r#"{
"bundle": {"resources": ["game.db", "manifests/*", "assets/*"]}
"bundle": {"resources": {
"production-catalog/game.db": "game.db",
"production-catalog/manifests/": "manifests/",
"assets/*": "assets/"
}}
}"#;
const DEVELOPMENT: &str = r#"{
"bundle": {"resources": {
@@ -204,6 +246,7 @@ mod tests {
base_config: PRODUCTION,
config_override,
fixture_development_opt_in,
local_development_opt_in: false,
cargo_profile: Some("release"),
out_dir: Some(Path::new("target/release/build/app/out")),
}
@@ -219,6 +262,13 @@ mod tests {
select_catalog_build_mode(input(Some(PRODUCTION), false)),
Ok(CatalogBuildMode::Production)
);
assert_eq!(
select_catalog_build_mode(input(
Some(r#"{"bundle":{"resources":["game.db","manifests/*","assets/*"]}}"#),
false
)),
Ok(CatalogBuildMode::Production)
);
assert_eq!(
select_catalog_build_mode(input(Some(r#"{"build": {}}"#), false)),
Ok(CatalogBuildMode::Production)
@@ -235,6 +285,48 @@ mod tests {
assert!(select_catalog_build_mode(input(None, true)).is_err());
}
#[test]
fn local_catalog_is_an_explicit_build_time_mode_excluded_from_production() {
let local = include_str!("../tauri.local.conf.json");
let mut local_input = input(Some(local), false);
assert!(select_catalog_build_mode(local_input).is_err());
local_input.local_development_opt_in = true;
assert_eq!(
select_catalog_build_mode(local_input),
Ok(CatalogBuildMode::LocalDevelopment)
);
for config in [PRODUCTION, DEVELOPMENT] {
assert!(
select_catalog_build_mode(CatalogGateInput {
config_override: Some(config),
..local_input
})
.is_err()
);
}
assert!(
select_catalog_build_mode(CatalogGateInput {
fixture_development_opt_in: true,
..local_input
})
.is_err()
);
assert!(
select_catalog_build_mode(CatalogGateInput {
cargo_profile: Some("production"),
..local_input
})
.is_err()
);
assert!(
select_catalog_build_mode(CatalogGateInput {
out_dir: Some(Path::new("target/production/build/app/out")),
..local_input
})
.is_err()
);
}
#[test]
fn production_profile_cannot_be_downgraded_to_fixture_authority() {
for mut input in [
@@ -260,7 +352,7 @@ mod tests {
fn incomplete_duplicated_or_unknown_resource_shapes_fail_closed() {
for config in [
r#"{"bundle":{"resources":["game.db","manifests/*"]}}"#,
r#"{"bundle":{"resources":["game.db","manifests/*","assets/*","assets/*"]}}"#,
r#"{"bundle":{"resources":{"production-catalog/game.db":"game.db"}}}"#,
r#"{"bundle":{"resources":{"fixture.db":"game.db"}}}"#,
r#"{"bundle":{"resources":true}}"#,
"not JSON",
@@ -0,0 +1,118 @@
use std::{
fs,
io,
path::{Path, PathBuf},
time::SystemTime,
};
pub(crate) struct CatalogResources {
files: Vec<(PathBuf, Option<SystemTime>)>,
}
impl CatalogResources {
pub(crate) fn prepare(out_dir: &Path, source: &Path) -> io::Result<Self> {
let profile_dir = profile_directory(out_dir)?;
let mut relative_files = vec![PathBuf::from("game.db")];
for entry in fs::read_dir(source.join("manifests"))? {
relative_files.push(Path::new("manifests").join(entry?.file_name()));
}
relative_files.sort();
let files = relative_files
.into_iter()
.map(|relative| {
let destination = profile_dir.join(&relative);
let previous_mtime = unchanged_mtime(&source.join(relative), &destination)?;
Ok((destination, previous_mtime))
})
.collect::<io::Result<Vec<_>>>()?;
reset_catalog_resources(out_dir)?;
Ok(Self { files })
}
pub(crate) fn finish(self) -> io::Result<Vec<PathBuf>> {
self.files
.into_iter()
.map(|(path, previous_mtime)| {
// Cargo compares watched files to the build-script start time.
// Tauri unconditionally copies, so retain the PREVIOUS OUTPUT
// mtime for identical bytes to let the next build become fresh.
// Changed bytes keep their new mtime: restoring source mtimes
// would hide another feature variant overwriting shared outputs.
if let Some(modified) = previous_mtime {
fs::OpenOptions::new()
.write(true)
.open(&path)?
.set_modified(modified)?;
}
Ok(path)
})
.collect()
}
}
fn unchanged_mtime(source: &Path, destination: &Path) -> io::Result<Option<SystemTime>> {
let metadata = match fs::symlink_metadata(destination) {
Ok(metadata) if metadata.is_file() && !metadata.is_symlink() => metadata,
Ok(_) => return Ok(None),
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
if metadata.len() == fs::metadata(source)?.len() && fs::read(source)? == fs::read(destination)?
{
Ok(Some(metadata.modified()?))
} else {
Ok(None)
}
}
/// Removes only Tauri's generated catalog copies before it copies the selected
/// authority. Tauri merges resource trees, which otherwise retains manifests
/// from a previous catalog mode or a larger previous production catalog.
pub(crate) fn reset_catalog_resources(out_dir: &Path) -> io::Result<[PathBuf; 2]> {
let profile_dir = profile_directory(out_dir)?;
let resources = [profile_dir.join("game.db"), profile_dir.join("manifests")];
for path in &resources {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.is_symlink() => {
return Err(io::Error::other(format!(
"generated catalog resource is a symlink: {}",
path.display()
)));
}
Ok(metadata) if metadata.is_dir() && path == &resources[1] => {
fs::remove_dir_all(path)?;
}
Ok(metadata) if metadata.is_file() => fs::remove_file(path)?,
Ok(_) => {
return Err(io::Error::other(format!(
"unexpected generated catalog resource type: {}",
path.display()
)));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Ok(resources)
}
fn profile_directory(out_dir: &Path) -> io::Result<&Path> {
// Match tauri-build's OUT_DIR -> profile directory calculation, including
// custom target directories, cross-compilation targets and Cargo profiles.
// Require Cargo's layout before deriving any removal target.
if !out_dir.is_absolute()
|| out_dir.file_name().is_none_or(|name| name != "out")
|| out_dir
.ancestors()
.nth(2)
.and_then(Path::file_name)
.is_none_or(|name| name != "build")
{
return Err(io::Error::other("unexpected Cargo OUT_DIR layout"));
}
out_dir
.ancestors()
.nth(3)
.ok_or_else(|| io::Error::other("Cargo OUT_DIR has no profile directory"))
}