Files
Starlight_Lancher/packages/app-lib/src/api/instance/synced_packs_axolotl.rs
T
AxTps 6e9485c2f5
Sync source to CNB / Sync Git ref (push) Has been cancelled
feat: 跨实例同步移植第5轮(orchestration 及循环依赖解耦)
- orchestration.rs:同步子系统编排核心(能力判定/全局与实例开关/重协调)

- command_history.rs + hotbars.rs:命令历史与创造模式快捷栏同步

- synced_packs_axolotl.rs:资源包/数据包共享投影(单文件实现)

- resource_pack_options.rs:resourcesPacks 列表读写与合并(synced_packs 依赖)

- 解决循环依赖:synced_options_path 归入 files.rs;SyncedOptionJoinResolution 归入 pending.rs

- instance_rows 补 set_instance_sync_preference;api/instance/mod.rs 补 synced_packs 内联模块与三块 pub use

- 6 处 sqlx::query! 宏改写为非宏

已知:未编译验证;local_settings_editor 尚未搬入。
2026-10-04 07:38:10 +08:00

1136 lines
38 KiB
Rust

//! Axolotl's shared resource/data-pack projection.
//!
//! The upstream implementation stores a launcher-wide JSON library and decorates
//! the upstream `ContentItem` with sync-only fields. Axolotl keeps content
//! ownership in SQLite, so this adapter stores only the shared catalog here and
//! lets the normal instance watcher materialize ownership in each content set.
use crate::api::instance::synced_servers::DesyncServerMode;
use crate::state::{
ContentItem, ContentItemOwner, ContentItemProject, ContentItemRollback,
ContentItemVersion, ContentOwnershipKind, ContentProvider,
ContentProviderRef, ContentRequirement, ContentSourceKind, ProjectType,
State,
};
use bytes::Bytes;
use chrono::Utc;
use serde::Serialize;
use sqlx::Row;
use std::collections::{BTreeSet, HashSet};
use std::path::{Component, Path, PathBuf};
#[derive(Clone, Debug, Serialize)]
pub struct PackSyncTarget {
pub instance_id: String,
pub name: String,
pub game_version: String,
pub compatible: bool,
pub participating: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackSyncPreview {
pub pack: ContentItem,
pub instances: Vec<PackSyncTarget>,
}
#[derive(Clone, Debug, sqlx::FromRow)]
struct PackRow {
id: String,
project_type: String,
file_name: String,
sha1: String,
size: i64,
game_versions_json: String,
enabled: i64,
}
fn parse_type(value: &str) -> crate::Result<ProjectType> {
match value {
"resourcepack" => Ok(ProjectType::ResourcePack),
"datapack" => Ok(ProjectType::DataPack),
other => Err(crate::ErrorKind::InputError(format!(
"Unsupported synced pack type: {other}"
))
.into()),
}
}
fn validate_type(project_type: ProjectType) -> crate::Result<()> {
if matches!(
project_type,
ProjectType::ResourcePack | ProjectType::DataPack
) {
Ok(())
} else {
Err(crate::ErrorKind::InputError(
"Only resource packs and data packs can be synced.".to_string(),
)
.into())
}
}
async fn globally_enabled(project_type: ProjectType) -> crate::Result<bool> {
let option = match project_type {
ProjectType::ResourcePack => crate::state::SyncedOption::ResourcePacks,
ProjectType::DataPack => crate::state::SyncedOption::DataPacks,
_ => return Ok(false),
};
Ok(crate::api::instance::synced_options::get_global_options()
.await?
.get(option))
}
fn cache_dir(state: &State) -> PathBuf {
state.directories.synced_options_dir().join("packs/files")
}
fn cache_path(state: &State, sha1: &str) -> PathBuf {
cache_dir(state).join(sha1)
}
fn content_root(
state: &State,
metadata: &crate::state::InstanceMetadata,
) -> crate::Result<PathBuf> {
crate::state::instances::commands::instance_content_root(
&state.directories,
&metadata.instance,
)
}
fn logical_path(
project_type: ProjectType,
file_name: &str,
enabled: bool,
) -> String {
format!(
"{}/{}{}",
project_type.get_folder(),
file_name.trim_end_matches(".disabled"),
if enabled { "" } else { ".disabled" }
)
}
fn game_versions(row: &PackRow) -> Vec<String> {
serde_json::from_str(&row.game_versions_json).unwrap_or_default()
}
fn resource_pack_option_entry(
metadata: &crate::state::InstanceMetadata,
entry: &str,
) -> String {
let legacy = metadata
.applied_content_set
.game_version
.strip_prefix("1.")
.and_then(|version| version.split('.').next()?.parse::<u32>().ok())
.is_some_and(|minor| minor < 13);
if legacy {
entry.strip_prefix("file/").unwrap_or(entry).to_string()
} else {
entry.to_string()
}
}
fn version_compatible(
row: &PackRow,
metadata: &crate::state::InstanceMetadata,
) -> bool {
let versions = game_versions(row);
versions.is_empty()
|| versions.iter().any(|version| {
version == &metadata.applied_content_set.game_version
})
}
fn safe_relative_path(path: &str) -> Option<PathBuf> {
let path = Path::new(path);
if path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir
| Component::RootDir
| Component::Prefix(_)
)
})
{
return None;
}
Some(path.to_path_buf())
}
async fn tracked_materialized_path(
state: &State,
pack_id: &str,
instance_id: &str,
) -> crate::Result<Option<String>> {
Ok(sqlx::query_scalar::<_, Option<String>>(
"SELECT materialized_path FROM synced_pack_instances
WHERE pack_id = ? AND instance_id = ?",
)
.bind(pack_id)
.bind(instance_id)
.fetch_optional(&state.pool)
.await?
.flatten())
}
fn normalize_tracked_path(
metadata: &crate::state::InstanceMetadata,
tracked: &str,
) -> Option<String> {
let normalized = tracked.replace('\\', "/");
let instance_path = metadata.instance.path.replace('\\', "/");
let relative = normalized
.strip_prefix(&format!("{instance_path}/"))
.unwrap_or(&normalized);
safe_relative_path(relative)
.map(|path| path.to_string_lossy().replace('\\', "/"))
}
async fn file_matches_sha1(path: &Path, expected: &str) -> bool {
match tokio::fs::read(path).await {
Ok(bytes) => crate::util::fetch::sha1_async(Bytes::from(bytes))
.await
.is_ok_and(|sha1| sha1 == expected),
Err(_) => false,
}
}
async fn cleanup_materialization(
state: &State,
row: &PackRow,
metadata: &crate::state::InstanceMetadata,
) -> crate::Result<()> {
let Some(tracked) =
tracked_materialized_path(state, &row.id, &metadata.instance.id)
.await?
else {
return Ok(());
};
let Some(relative_path) = normalize_tracked_path(metadata, &tracked) else {
tracing::warn!(
"Ignoring unsafe synced-pack materialized path for {}: {tracked}",
metadata.instance.id
);
return Ok(());
};
let absolute = content_root(state, metadata)?.join(&relative_path);
if file_matches_sha1(&absolute, &row.sha1).await {
match tokio::fs::remove_file(&absolute).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
let mut transaction = state.pool.begin().await?;
sqlx::query(
"DELETE FROM instance_pack_members
WHERE member_key = ?
AND content_set_id IN (
SELECT id FROM instance_content_sets WHERE instance_id = ?
)",
)
.bind(format!("shared:{}", row.id))
.bind(&metadata.instance.id)
.execute(&mut *transaction)
.await?;
sqlx::query(
"DELETE FROM instance_content_entries
WHERE instance_id = ?
AND source_kind = 'shared_instance'
AND file_id IN (
SELECT id FROM instance_files
WHERE instance_id = ? AND relative_path = ?
)",
)
.bind(&metadata.instance.id)
.bind(&metadata.instance.id)
.bind(&relative_path)
.execute(&mut *transaction)
.await?;
sqlx::query(
"DELETE FROM instance_files
WHERE instance_id = ? AND relative_path = ?
AND NOT EXISTS (
SELECT 1 FROM instance_content_entries
WHERE file_id = instance_files.id
)",
)
.bind(&metadata.instance.id)
.bind(&relative_path)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
async fn record_materialization(
state: &State,
pack_id: &str,
instance_id: &str,
excluded: bool,
materialized_path: Option<&str>,
) -> crate::Result<()> {
sqlx::query(
"INSERT INTO synced_pack_instances
(pack_id, instance_id, excluded, materialized_path, modified_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(pack_id, instance_id) DO UPDATE SET
excluded=excluded.excluded,
materialized_path=excluded.materialized_path,
modified_at=excluded.modified_at",
)
.bind(pack_id)
.bind(instance_id)
.bind(i64::from(excluded))
.bind(materialized_path)
.bind(Utc::now().timestamp())
.execute(&state.pool)
.await?;
Ok(())
}
fn row_item(row: &PackRow) -> crate::Result<ContentItem> {
let project_type = parse_type(&row.project_type)?;
let file_name = row.file_name.trim_end_matches(".disabled").to_string();
Ok(ContentItem {
file_name: file_name.clone(),
file_path: logical_path(project_type, &file_name, row.enabled != 0),
id: row.id.clone(),
size: row.size.max(0) as u64,
enabled: row.enabled != 0,
project_type,
project: None::<ContentItemProject>,
version: None::<ContentItemVersion>,
owner: None::<ContentItemOwner>,
update: None,
date_added: None,
provider_refs: Vec::<ContentProviderRef>::new(),
origin_provider: None::<ContentProvider>,
rollback: None::<ContentItemRollback>,
environment: None,
source_kind: Some(ContentSourceKind::SharedInstance),
external: true,
loader: None,
})
}
async fn load_row(id: &str, state: &State) -> crate::Result<PackRow> {
sqlx::query_as::<_, PackRow>(
"SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled
FROM synced_pack_catalog WHERE id = ?",
)
.bind(id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| crate::ErrorKind::InputError("Unknown synced pack".to_string()).into())
}
async fn write_cache(
state: &State,
bytes: &Bytes,
sha1: &str,
) -> crate::Result<()> {
tokio::fs::create_dir_all(cache_dir(state)).await?;
let path = cache_path(state, sha1);
if !tokio::fs::try_exists(&path).await? {
tokio::fs::write(path, bytes).await?;
}
Ok(())
}
async fn materialize(
state: &State,
row: &PackRow,
instance_id: &str,
excluded: bool,
) -> crate::Result<()> {
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let project_type = parse_type(&row.project_type)?;
let root = content_root(state, &metadata)?;
let relative_path =
logical_path(project_type, &row.file_name, row.enabled != 0);
let destination = root.join(&relative_path);
if excluded
|| !globally_enabled(project_type).await?
|| !metadata.synced_options_for(project_type)
|| !version_compatible(row, &metadata)
{
cleanup_materialization(state, row, &metadata).await?;
record_materialization(state, &row.id, instance_id, excluded, None)
.await?;
return Ok(());
}
let tracked =
tracked_materialized_path(state, &row.id, instance_id).await?;
let owns_destination = tracked
.as_deref()
.and_then(|path| normalize_tracked_path(&metadata, path))
.is_some_and(|path| path == relative_path);
if tokio::fs::try_exists(&destination).await?
&& !owns_destination
&& !file_matches_sha1(&destination, &row.sha1).await
{
return Err(crate::ErrorKind::InputError(format!(
"Cannot sync {} because {} already contains a different local file.",
row.file_name,
destination.display()
))
.into());
}
if tracked.as_deref().is_some_and(|path| {
normalize_tracked_path(&metadata, path)
.is_some_and(|path| path != relative_path)
}) {
cleanup_materialization(state, row, &metadata).await?;
}
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let bytes = tokio::fs::read(cache_path(state, &row.sha1)).await?;
let destination_matches = tokio::fs::read(&destination)
.await
.is_ok_and(|existing| existing == bytes);
if !destination_matches {
tokio::fs::write(&destination, &bytes).await?;
}
let file = crate::state::instances::adapters::sqlite::content_rows::upsert_instance_file_from_parts(
crate::state::instances::adapters::sqlite::content_rows::UpsertInstanceFile {
instance_id,
relative_path: &relative_path,
file_name: &row.file_name,
enabled: row.enabled != 0,
sha1: &row.sha1,
size: row.size.max(0) as u64,
missing: false,
local_mod_data: None,
icon_path: None,
},
&state.pool,
)
.await?;
let entry = crate::state::instances::adapters::sqlite::content_rows::upsert_content_entry_from_parts(
crate::state::instances::adapters::sqlite::content_rows::UpsertContentEntry {
instance_id,
content_set_id: &metadata.applied_content_set.id,
file_id: Some(&file.id),
project_type,
source_kind: ContentSourceKind::SharedInstance,
ownership_kind: ContentOwnershipKind::PackManaged,
auto_dependency: false,
server_requirement: ContentRequirement::Optional,
client_requirement: ContentRequirement::Optional,
enabled: row.enabled != 0,
},
&state.pool,
)
.await?;
sqlx::query(
"INSERT INTO instance_pack_members
(id, content_set_id, content_entry_id, member_key, project_type,
expected_relative_path, required, expected_sha1, expected_size,
materialization_state, override_kind, reconciled, created_at, modified_at)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, 'present', 'none', 1, ?, ?)
ON CONFLICT(content_set_id, member_key) DO UPDATE SET
content_entry_id=excluded.content_entry_id,
expected_sha1=excluded.expected_sha1, expected_size=excluded.expected_size,
materialization_state='present', override_kind='none',
reconciled=1, modified_at=excluded.modified_at",
)
.bind(format!("pack-member:{}", row.id))
.bind(&metadata.applied_content_set.id)
.bind(&entry.id)
.bind(format!("shared:{}", row.id))
.bind(project_type.get_name())
.bind(&relative_path)
.bind(&row.sha1)
.bind(row.size)
.bind(Utc::now().timestamp())
.bind(Utc::now().timestamp())
.execute(&state.pool)
.await?;
record_materialization(
state,
&row.id,
instance_id,
excluded,
Some(&relative_path),
)
.await?;
Ok(())
}
trait SyncedPackInstanceOptions {
fn synced_options_for(&self, project_type: ProjectType) -> bool;
}
impl SyncedPackInstanceOptions for crate::state::InstanceMetadata {
fn synced_options_for(&self, project_type: ProjectType) -> bool {
match project_type {
ProjectType::ResourcePack => self.synced_options.resource_packs,
ProjectType::DataPack => self.synced_options.data_packs,
_ => false,
}
}
}
pub async fn list_synced_packs(
project_type: ProjectType,
) -> crate::Result<Vec<ContentItem>> {
validate_type(project_type)?;
let state = State::get().await?;
let rows = sqlx::query_as::<_, PackRow>(
"SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled
FROM synced_pack_catalog WHERE project_type = ? ORDER BY modified_at DESC",
)
.bind(project_type.get_name())
.fetch_all(&state.pool)
.await?;
rows.iter().map(row_item).collect()
}
pub async fn get_pack_sync_preview(
instance_id: &str,
project_path: &str,
) -> crate::Result<PackSyncPreview> {
let state = State::get().await?;
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let project_type = project_path
.split('/')
.next()
.and_then(|folder| match folder {
"resourcepacks" => Some(ProjectType::ResourcePack),
"datapacks" => Some(ProjectType::DataPack),
_ => None,
})
.ok_or_else(|| {
crate::ErrorKind::InputError("Invalid pack path".to_string())
})?;
validate_type(project_type)?;
let global_enabled = globally_enabled(project_type).await?;
let source = content_root(&state, &metadata)?.join(project_path);
let bytes = Bytes::from(tokio::fs::read(&source).await?);
let sha1 = crate::util::fetch::sha1_async(bytes.clone()).await?;
let row = sqlx::query_as::<_, PackRow>(
"SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled
FROM synced_pack_catalog WHERE sha1 = ?",
).bind(&sha1).fetch_optional(&state.pool).await?;
let preview_row = row.unwrap_or(PackRow {
id: format!("synced-pack:{sha1}"),
project_type: project_type.get_name().to_string(),
file_name: Path::new(project_path)
.file_name()
.and_then(|x| x.to_str())
.unwrap_or("pack.zip")
.to_string(),
sha1,
size: bytes.len() as i64,
game_versions_json: "[]".to_string(),
enabled: 1,
});
let pack = row_item(&preview_row)?;
let instances = crate::state::list_instances(&state.pool)
.await?
.into_iter()
.map(|item| {
let participating =
global_enabled && item.synced_options_for(project_type);
let versions = game_versions(&preview_row);
let compatible = versions.is_empty()
|| versions.iter().any(|version| {
version == &item.applied_content_set.game_version
});
PackSyncTarget {
instance_id: item.instance.id,
name: item.instance.name,
game_version: item.applied_content_set.game_version,
compatible,
participating,
}
})
.collect();
Ok(PackSyncPreview { pack, instances })
}
pub async fn sync_pack(
instance_id: &str,
project_path: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let preview = get_pack_sync_preview(instance_id, project_path).await?;
if !globally_enabled(preview.pack.project_type).await? {
return Err(crate::ErrorKind::InputError(
"This sync option is globally disabled.".to_string(),
)
.into());
}
if !preview
.instances
.iter()
.any(|target| target.instance_id == instance_id && target.participating)
{
return Err(crate::ErrorKind::InputError(
"This sync option is disabled for the source instance.".to_string(),
)
.into());
}
let source_metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let source = content_root(&state, &source_metadata)?.join(project_path);
let bytes = Bytes::from(tokio::fs::read(source).await?);
let sha1 = crate::util::fetch::sha1_async(bytes.clone()).await?;
write_cache(&state, &bytes, &sha1).await?;
let now = Utc::now().timestamp();
let row = sqlx::query("INSERT INTO synced_pack_catalog(id, project_type, file_name, sha1, size, game_versions_json, enabled, created_at, modified_at) VALUES (?, ?, ?, ?, ?, '[]', 1, ?, ?) ON CONFLICT(sha1) DO UPDATE SET modified_at=excluded.modified_at RETURNING id, project_type, file_name, sha1, size, game_versions_json, enabled").bind(&preview.pack.id).bind(preview.pack.project_type.get_name()).bind(&preview.pack.file_name).bind(&sha1).bind(bytes.len() as i64).bind(now).bind(now).fetch_one(&state.pool).await?;
let catalog = PackRow {
id: row.try_get("id")?,
project_type: row.try_get("project_type")?,
file_name: row.try_get("file_name")?,
sha1: row.try_get("sha1")?,
size: row.try_get("size")?,
game_versions_json: row.try_get("game_versions_json")?,
enabled: row.try_get("enabled")?,
};
for target in preview
.instances
.into_iter()
.filter(|target| target.participating)
{
materialize(&state, &catalog, &target.instance_id, false).await?;
}
Ok(())
}
pub async fn upload_synced_pack(
path: PathBuf,
project_type: ProjectType,
game_versions: Vec<String>,
) -> crate::Result<()> {
validate_type(project_type)?;
let state = State::get().await?;
if !globally_enabled(project_type).await? {
return Err(crate::ErrorKind::InputError(
"This sync option is globally disabled.".to_string(),
)
.into());
}
let bytes = Bytes::from(tokio::fs::read(&path).await?);
let sha1 = crate::util::fetch::sha1_async(bytes.clone()).await?;
write_cache(&state, &bytes, &sha1).await?;
let name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| {
crate::ErrorKind::InputError("Invalid pack filename".to_string())
})?
.to_string();
let now = Utc::now().timestamp();
sqlx::query("INSERT INTO synced_pack_catalog(id, project_type, file_name, sha1, size, game_versions_json, enabled, created_at, modified_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) ON CONFLICT(sha1) DO UPDATE SET file_name=excluded.file_name, game_versions_json=excluded.game_versions_json, modified_at=excluded.modified_at")
.bind(format!("synced-pack:{sha1}"))
.bind(project_type.get_name()).bind(name).bind(&sha1).bind(bytes.len() as i64)
.bind(serde_json::to_string(&game_versions)?).bind(now).bind(now).execute(&state.pool).await?;
Ok(())
}
pub async fn set_synced_pack_enabled(
pack_id: &str,
enabled: bool,
) -> crate::Result<()> {
let state = State::get().await?;
let mut row = load_row(pack_id, &state).await?;
if !globally_enabled(parse_type(&row.project_type)?).await? {
return Err(crate::ErrorKind::InputError(
"This sync option is globally disabled.".to_string(),
)
.into());
}
sqlx::query("UPDATE synced_pack_catalog SET enabled = ?, modified_at = ? WHERE id = ?").bind(i64::from(enabled)).bind(Utc::now().timestamp()).bind(pack_id).execute(&state.pool).await?;
row.enabled = i64::from(enabled);
for target in sqlx::query("SELECT instance_id FROM synced_pack_instances WHERE pack_id = ? AND excluded = 0").bind(pack_id).fetch_all(&state.pool).await? { materialize(&state, &row, target.try_get("instance_id")?, false).await?; }
Ok(())
}
pub async fn desync_pack(
instance_id: &str,
pack_id: &str,
mode: DesyncServerMode,
) -> crate::Result<()> {
let state = State::get().await?;
let row = load_row(pack_id, &state).await?;
materialize(&state, &row, instance_id, true).await?;
if mode == DesyncServerMode::RemoveFromOtherInstances {
let targets = sqlx::query(
"SELECT instance_id FROM synced_pack_instances
WHERE pack_id = ? AND instance_id != ?",
)
.bind(pack_id)
.bind(instance_id)
.fetch_all(&state.pool)
.await?;
for target in targets {
let target_id: String = target.try_get("instance_id")?;
if let Some(metadata) =
crate::state::get_instance(&target_id, &state.pool).await?
{
cleanup_materialization(&state, &row, &metadata).await?;
}
}
sqlx::query(
"DELETE FROM synced_pack_instances WHERE pack_id = ? AND instance_id != ?",
)
.bind(pack_id)
.bind(instance_id)
.execute(&state.pool)
.await?;
}
Ok(())
}
pub async fn remove_synced_pack(pack_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let row = load_row(pack_id, &state).await?;
let targets = sqlx::query(
"SELECT instance_id FROM synced_pack_instances WHERE pack_id = ?",
)
.bind(pack_id)
.fetch_all(&state.pool)
.await?;
for target in targets {
let target_id: String = target.try_get("instance_id")?;
if let Some(metadata) =
crate::state::get_instance(&target_id, &state.pool).await?
{
cleanup_materialization(&state, &row, &metadata).await?;
}
}
sqlx::query("DELETE FROM synced_pack_catalog WHERE id = ?")
.bind(pack_id)
.execute(&state.pool)
.await?;
Ok(())
}
pub(crate) async fn detach(
metadata: &crate::state::InstanceMetadata,
option: crate::state::SyncedOption,
state: &State,
) -> crate::Result<()> {
let project_type = match option {
crate::state::SyncedOption::ResourcePacks => ProjectType::ResourcePack,
crate::state::SyncedOption::DataPacks => ProjectType::DataPack,
_ => return Ok(()),
};
let rows = sqlx::query_as::<_, PackRow>(
"SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled
FROM synced_pack_catalog WHERE project_type = ?",
)
.bind(project_type.get_name())
.fetch_all(&state.pool)
.await?;
for row in rows {
let excluded = sqlx::query_scalar::<_, i64>(
"SELECT excluded FROM synced_pack_instances
WHERE pack_id = ? AND instance_id = ?",
)
.bind(&row.id)
.bind(&metadata.instance.id)
.fetch_optional(&state.pool)
.await?
.unwrap_or(0)
!= 0;
cleanup_materialization(state, &row, metadata).await?;
record_materialization(
state,
&row.id,
&metadata.instance.id,
excluded,
None,
)
.await?;
}
Ok(())
}
pub(crate) async fn prepare_instance_update(
metadata: &crate::state::InstanceMetadata,
state: &State,
) -> crate::Result<()> {
// Remove only files owned by the shared catalog before an install or
// upgrade writes the instance directory. User-owned files are preserved by
// cleanup_materialization's content hash check and remain local overrides.
for project_type in [ProjectType::ResourcePack, ProjectType::DataPack] {
if !metadata.synced_options_for(project_type) {
continue;
}
let rows = sqlx::query_as::<_, PackRow>(
"SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled
FROM synced_pack_catalog WHERE project_type = ?",
)
.bind(project_type.get_name())
.fetch_all(&state.pool)
.await?;
for row in rows {
let excluded = sqlx::query_scalar::<_, i64>(
"SELECT excluded FROM synced_pack_instances
WHERE pack_id = ? AND instance_id = ?",
)
.bind(&row.id)
.bind(&metadata.instance.id)
.fetch_optional(&state.pool)
.await?
.unwrap_or(0)
!= 0;
cleanup_materialization(state, &row, metadata).await?;
record_materialization(
state,
&row.id,
&metadata.instance.id,
excluded,
None,
)
.await?;
}
}
Ok(())
}
pub(crate) async fn reconcile(
metadata: &crate::state::InstanceMetadata,
option: crate::state::SyncedOption,
state: &State,
) -> crate::Result<()> {
let project_type = match option {
crate::state::SyncedOption::ResourcePacks => ProjectType::ResourcePack,
crate::state::SyncedOption::DataPacks => ProjectType::DataPack,
_ => return Ok(()),
};
let rows = sqlx::query_as::<_, PackRow>(
"SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled
FROM synced_pack_catalog WHERE project_type = ?",
)
.bind(project_type.get_name())
.fetch_all(&state.pool)
.await?;
for row in rows {
let excluded = sqlx::query_scalar::<_, i64>(
"SELECT excluded FROM synced_pack_instances WHERE pack_id = ? AND instance_id = ?",
)
.bind(&row.id)
.bind(&metadata.instance.id)
.fetch_optional(&state.pool)
.await?
.unwrap_or(0)
!= 0;
materialize(state, &row, &metadata.instance.id, excluded).await?;
}
Ok(())
}
pub(crate) async fn capture_resource_pack_selection_change(
metadata: &crate::state::InstanceMetadata,
state: &State,
) -> crate::Result<()> {
let Some(options) = crate::api::instance::synced_options::game_options::read_resource_pack_entries(metadata, state).await? else {
return Ok(());
};
let global_enabled = globally_enabled(ProjectType::ResourcePack).await?;
if !global_enabled || !metadata.synced_options.resource_packs {
return Ok(());
}
let rows = sqlx::query(
"SELECT id, file_name, selected FROM synced_pack_catalog
WHERE project_type = 'resourcepack'",
)
.fetch_all(&state.pool)
.await?;
let mut managed = BTreeSet::new();
let mut selected_names = BTreeSet::new();
let mut transaction = state.pool.begin().await?;
for (order, row) in rows.iter().enumerate() {
let id: String = row.try_get("id")?;
let file_name: String = row.try_get("file_name")?;
let entry = format!("file/{file_name}");
let legacy = file_name.clone();
let is_selected = options
.entries
.iter()
.any(|candidate| candidate == &entry || candidate == &legacy);
managed.insert(entry.clone());
managed.insert(legacy);
sqlx::query(
"UPDATE synced_pack_catalog
SET selected = ?, selection_order = ?
WHERE id = ?",
)
.bind(i64::from(is_selected))
.bind(
options
.entries
.iter()
.position(|candidate| {
candidate == &entry || candidate == &file_name
})
.map(|value| value as i64)
.or(Some(order as i64)),
)
.bind(id)
.execute(&mut *transaction)
.await?;
if is_selected {
selected_names.insert(entry);
}
}
transaction.commit().await?;
let selected = options
.entries
.iter()
.filter(|entry| managed.contains(*entry))
.filter(|entry| {
selected_names.contains(*entry)
|| selected_names
.contains(entry.strip_prefix("file/").unwrap_or(entry))
})
.cloned()
.collect::<Vec<_>>();
for target in crate::state::list_instances(&state.pool).await? {
if target.instance.id == metadata.instance.id
|| !target.synced_options.resource_packs
{
continue;
}
let target_selected = selected
.iter()
.map(|entry| resource_pack_option_entry(&target, entry))
.collect::<Vec<_>>();
let _ = crate::api::instance::synced_options::game_options::merge_resource_pack_entries(
&target,
&managed,
&target_selected,
state,
)
.await?;
}
Ok(())
}
pub(crate) async fn seed_from_instance(
metadata: &crate::state::InstanceMetadata,
option: crate::state::SyncedOption,
state: &State,
) -> crate::Result<()> {
let project_type = match option {
crate::state::SyncedOption::ResourcePacks => ProjectType::ResourcePack,
crate::state::SyncedOption::DataPacks => ProjectType::DataPack,
_ => return Ok(()),
};
let root = content_root(state, metadata)?;
let directory = root.join(project_type.get_folder());
let mut entries = match tokio::fs::read_dir(&directory).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(());
}
Err(error) => return Err(error.into()),
};
let game_versions_json =
serde_json::to_string(&[&metadata.applied_content_set.game_version])?;
let mut seen_sha1 = HashSet::new();
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_file() {
continue;
}
let path = entry.path();
let Some(file_name) = entry.file_name().to_str().map(ToOwned::to_owned)
else {
continue;
};
let bytes = Bytes::from(tokio::fs::read(&path).await?);
let sha1 = crate::util::fetch::sha1_async(bytes.clone()).await?;
seen_sha1.insert(sha1.clone());
write_cache(state, &bytes, &sha1).await?;
let enabled = !file_name.ends_with(".disabled");
let file_name = file_name.trim_end_matches(".disabled");
let now = Utc::now().timestamp();
let replaced_rows = sqlx::query_as::<_, PackRow>(
"SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled
FROM synced_pack_catalog
WHERE project_type = ? AND file_name = ? AND sha1 != ?",
)
.bind(project_type.get_name())
.bind(file_name)
.bind(&sha1)
.fetch_all(&state.pool)
.await?;
for replaced in replaced_rows {
let targets = sqlx::query(
"SELECT instance_id FROM synced_pack_instances WHERE pack_id = ?",
)
.bind(&replaced.id)
.fetch_all(&state.pool)
.await?;
for target in targets {
let target_id: String = target.try_get("instance_id")?;
if let Some(target_metadata) =
crate::state::get_instance(&target_id, &state.pool).await?
{
cleanup_materialization(state, &replaced, &target_metadata)
.await?;
}
}
}
let mut transaction = state.pool.begin().await?;
sqlx::query(
"DELETE FROM synced_pack_catalog
WHERE project_type = ? AND file_name = ? AND sha1 != ?",
)
.bind(project_type.get_name())
.bind(file_name)
.bind(&sha1)
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO synced_pack_catalog
(id, project_type, file_name, sha1, size, game_versions_json,
enabled, created_at, modified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sha1) DO UPDATE SET
file_name=excluded.file_name,
game_versions_json=excluded.game_versions_json,
enabled=excluded.enabled,
modified_at=excluded.modified_at",
)
.bind(format!("synced-pack:{sha1}"))
.bind(project_type.get_name())
.bind(file_name)
.bind(&sha1)
.bind(bytes.len() as i64)
.bind(&game_versions_json)
.bind(i64::from(enabled))
.bind(now)
.bind(now)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
}
// A watcher event can report a directory after a file was removed. Reconcile
// catalog entries materialized in this source instance so stale files do not
// remain in other participating instances.
let tracked = sqlx::query(
"SELECT c.id, c.project_type, c.file_name, c.sha1, c.size,
c.game_versions_json, c.enabled, i.materialized_path
FROM synced_pack_catalog c
JOIN synced_pack_instances i ON i.pack_id = c.id
WHERE c.project_type = ? AND i.instance_id = ?
AND i.materialized_path IS NOT NULL",
)
.bind(project_type.get_name())
.bind(&metadata.instance.id)
.fetch_all(&state.pool)
.await?;
for tracked_row in tracked {
let row = PackRow {
id: tracked_row.try_get("id")?,
project_type: tracked_row.try_get("project_type")?,
file_name: tracked_row.try_get("file_name")?,
sha1: tracked_row.try_get("sha1")?,
size: tracked_row.try_get("size")?,
game_versions_json: tracked_row.try_get("game_versions_json")?,
enabled: tracked_row.try_get("enabled")?,
};
if seen_sha1.contains(&row.sha1) {
continue;
}
let Some(path) = tracked_row
.try_get::<Option<String>, _>("materialized_path")?
.and_then(|path| normalize_tracked_path(metadata, &path))
else {
continue;
};
if tokio::fs::try_exists(&content_root(&state, &metadata)?.join(path))
.await?
{
continue;
}
let targets = sqlx::query(
"SELECT instance_id FROM synced_pack_instances WHERE pack_id = ?",
)
.bind(&row.id)
.fetch_all(&state.pool)
.await?;
for target in targets {
let target_id: String = target.try_get("instance_id")?;
if let Some(target_metadata) =
crate::state::get_instance(&target_id, &state.pool).await?
{
cleanup_materialization(state, &row, &target_metadata).await?;
}
}
sqlx::query("DELETE FROM synced_pack_catalog WHERE id = ?")
.bind(&row.id)
.execute(&state.pool)
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logical_paths_preserve_disabled_state() {
assert_eq!(
logical_path(ProjectType::ResourcePack, "example.zip", true),
"resourcepacks/example.zip"
);
assert_eq!(
logical_path(ProjectType::DataPack, "example.zip", false),
"datapacks/example.zip.disabled"
);
}
#[test]
fn unsafe_materialized_paths_are_rejected() {
assert!(safe_relative_path("resourcepacks/example.zip").is_some());
assert!(safe_relative_path("../outside.zip").is_none());
assert!(safe_relative_path("/absolute.zip").is_none());
assert!(
safe_relative_path("resourcepacks/../../outside.zip").is_none()
);
}
#[test]
fn malformed_game_version_metadata_is_treated_as_unrestricted() {
let row = PackRow {
id: "pack".to_string(),
project_type: "resourcepack".to_string(),
file_name: "example.zip".to_string(),
sha1: "sha1".to_string(),
size: 1,
game_versions_json: "not-json".to_string(),
enabled: 1,
};
assert!(game_versions(&row).is_empty());
}
}