diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index 4b06a48..22a28bb 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -23,6 +23,17 @@ export type GameInstance = { loader_version?: string loader_components: LoaderComponent[] + synced_options: Record< + | 'game_options' + | 'command_history' + | 'multiplayer_servers' + | 'creative_hotbars' + | 'screenshots' + | 'resource_packs' + | 'data_packs', + boolean + > + groups: string[] link?: InstanceLink | null diff --git a/packages/app-lib/migrations/20261003000000_instance-sync-feature-tables.sql b/packages/app-lib/migrations/20261003000000_instance-sync-feature-tables.sql new file mode 100644 index 0000000..3457c1b --- /dev/null +++ b/packages/app-lib/migrations/20261003000000_instance-sync-feature-tables.sql @@ -0,0 +1,189 @@ +-- Instance settings synchronization: shared feature tables. +-- +-- Split out of Axolotl's combined "instance-sync-and-screenshot-center" +-- migration so that the screenshot-center tables (already present in this +-- fork under its own migration) are not created twice. + +CREATE TABLE sync_feature_settings ( + feature TEXT PRIMARY KEY NOT NULL, + globally_enabled INTEGER NOT NULL CHECK (globally_enabled IN (0, 1)), + new_instance_default INTEGER NOT NULL CHECK (new_instance_default IN (0, 1)) +); + +INSERT INTO sync_feature_settings (feature, globally_enabled, new_instance_default) +VALUES + ('command_history', 0, 0), + ('multiplayer_servers', 0, 0), + ('creative_hotbars', 0, 0), + ('screenshots', 0, 0), + ('game_options', 0, 0), + ('resource_packs', 0, 0), + ('data_packs', 0, 0); + +CREATE TABLE instance_sync_preferences ( + instance_id TEXT NOT NULL, + feature TEXT NOT NULL, + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + PRIMARY KEY (instance_id, feature), + FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE, + FOREIGN KEY (feature) REFERENCES sync_feature_settings (feature) ON DELETE CASCADE +); + +CREATE INDEX instance_sync_preferences_feature_enabled + ON instance_sync_preferences (feature, enabled); + +INSERT INTO instance_sync_preferences (instance_id, feature, enabled) +SELECT instances.id, features.feature, features.enabled +FROM instances +CROSS JOIN ( + SELECT 'command_history' AS feature, 0 AS enabled + UNION ALL SELECT 'multiplayer_servers', 0 + UNION ALL SELECT 'creative_hotbars', 0 + UNION ALL SELECT 'screenshots', 0 +) AS features; + +INSERT INTO instance_sync_preferences (instance_id, feature, enabled) +SELECT id, 'game_options', 0 FROM instances; + +INSERT INTO instance_sync_preferences (instance_id, feature, enabled) +SELECT instances.id, features.feature, 0 +FROM instances +CROSS JOIN ( + SELECT 'resource_packs' AS feature + UNION ALL SELECT 'data_packs' +) AS features; + +CREATE TABLE synced_game_option_state ( + singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), + revision INTEGER NOT NULL CHECK (revision >= 0), + catalog_revision INTEGER NOT NULL CHECK (catalog_revision >= 1) +); + +CREATE TABLE synced_game_option_values ( + option_id TEXT PRIMARY KEY NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('vanilla', 'external')), + raw_key TEXT, + canonical_type TEXT NOT NULL, + canonical_value_json TEXT, + value_codec TEXT NOT NULL, + seeded INTEGER NOT NULL CHECK (seeded IN (0, 1)), + revision INTEGER NOT NULL CHECK (revision >= 0), + origin TEXT NOT NULL CHECK (origin IN ('app_editor', 'instance', 'source_seed')), + source_game_version TEXT, + source_instance_id TEXT, + updated_at INTEGER NOT NULL +); + +CREATE TABLE synced_game_option_preferences ( + option_id TEXT PRIMARY KEY NOT NULL, + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + source TEXT NOT NULL CHECK (source IN ('catalog_default', 'discovery_default', 'user')), + revision INTEGER NOT NULL CHECK (revision >= 0) +); + +CREATE TABLE instance_game_option_pack_bases ( + instance_id TEXT PRIMARY KEY NOT NULL, + pack_version_id TEXT, + source TEXT NOT NULL CHECK (source IN ('client_overrides', 'overrides', 'none')), + sha1 TEXT, + encoding TEXT, + document BLOB, + FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE +); + +CREATE TABLE instance_game_option_update_state ( + instance_id TEXT PRIMARY KEY NOT NULL, + had_file INTEGER NOT NULL CHECK (had_file IN (0, 1)), + sha1 TEXT, + document BLOB, + FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE +); + +CREATE TABLE game_option_locale_origins ( + scope TEXT NOT NULL, + option_id TEXT NOT NULL, + source_instance_id TEXT, + source_game_version TEXT, + backfilled INTEGER NOT NULL DEFAULT 0, + observation_json TEXT, + origin_json TEXT, + PRIMARY KEY (scope, option_id) +); + +INSERT INTO game_option_locale_origins + (scope, option_id, source_instance_id, source_game_version, backfilled) +SELECT '', option_id, source_instance_id, source_game_version, 1 +FROM synced_game_option_values; + +CREATE TRIGGER record_game_option_locale_origin +AFTER INSERT ON synced_game_option_values +BEGIN + INSERT INTO game_option_locale_origins + (scope, option_id, source_instance_id, source_game_version) + VALUES ('', NEW.option_id, NEW.source_instance_id, NEW.source_game_version) + ON CONFLICT(scope, option_id) DO NOTHING; +END; + +CREATE TABLE instance_sync_checkpoints ( + instance_id TEXT NOT NULL, + feature TEXT NOT NULL, + variant TEXT NOT NULL CHECK (variant IN ('default', 'legacy', 'components')), + expected_sha1 TEXT NOT NULL, + merge_base BLOB, + source_revision INTEGER NOT NULL CHECK (source_revision >= 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'ready')), + link_mode TEXT CHECK (link_mode IS NULL OR link_mode IN ('copy', 'hard', 'symbolic')), + PRIMARY KEY (instance_id, feature, variant), + FOREIGN KEY (instance_id, feature) + REFERENCES instance_sync_preferences (instance_id, feature) ON DELETE CASCADE +); + +CREATE TABLE synced_hotbar_state ( + singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), + schema_version INTEGER NOT NULL CHECK (schema_version >= 1), + revision INTEGER NOT NULL CHECK (revision >= 0), + nbt BLOB NOT NULL +); + +CREATE TABLE synced_server_state ( + singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), + revision INTEGER NOT NULL CHECK (revision >= 0) +); + +CREATE TABLE synced_servers ( + id TEXT PRIMARY KEY NOT NULL, + position INTEGER NOT NULL UNIQUE CHECK (position >= 0), + nbt BLOB NOT NULL +); + +CREATE TABLE instance_servers ( + instance_id TEXT NOT NULL, + id TEXT NOT NULL, + source TEXT NOT NULL CHECK (source IN ('modpack', 'local_desynced')), + excluded_synced_server_id TEXT, + nbt BLOB NOT NULL, + position INTEGER NOT NULL CHECK (position >= 0), + PRIMARY KEY (instance_id, id), + UNIQUE (instance_id, position), + FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE, + FOREIGN KEY (excluded_synced_server_id) REFERENCES synced_servers (id) ON DELETE SET NULL +); + +CREATE TABLE instance_server_projection_entries ( + instance_id TEXT NOT NULL, + owner TEXT NOT NULL CHECK (owner IN ('synced', 'instance')), + server_id TEXT NOT NULL, + nbt BLOB NOT NULL, + position INTEGER NOT NULL CHECK (position >= 0), + PRIMARY KEY (instance_id, owner, server_id), + UNIQUE (instance_id, position), + FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE +); + +CREATE TABLE instance_server_pack_state ( + instance_id TEXT PRIMARY KEY NOT NULL, + version_id TEXT, + FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE +); + +ALTER TABLE settings ADD COLUMN sync_features_across_devices INTEGER NOT NULL DEFAULT FALSE; diff --git a/packages/app-lib/migrations/20261003000100_linked-server-project-sync-source.sql b/packages/app-lib/migrations/20261003000100_linked-server-project-sync-source.sql new file mode 100644 index 0000000..490e642 --- /dev/null +++ b/packages/app-lib/migrations/20261003000100_linked-server-project-sync-source.sql @@ -0,0 +1,27 @@ +-- Axolotl stores managed server-project entries alongside the local additions +-- that are composed with the launcher-wide multiplayer server list. Extend +-- the original sync table constraint without changing the already-applied +-- instance-sync migration. +CREATE TABLE instance_servers_new ( + instance_id TEXT NOT NULL, + id TEXT NOT NULL, + source TEXT NOT NULL CHECK ( + source IN ('modpack', 'linked_server_project', 'local_desynced') + ), + excluded_synced_server_id TEXT, + nbt BLOB NOT NULL, + position INTEGER NOT NULL CHECK (position >= 0), + PRIMARY KEY (instance_id, id), + UNIQUE (instance_id, position), + FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE, + FOREIGN KEY (excluded_synced_server_id) + REFERENCES synced_servers (id) ON DELETE SET NULL +); + +INSERT INTO instance_servers_new + (instance_id, id, source, excluded_synced_server_id, nbt, position) +SELECT instance_id, id, source, excluded_synced_server_id, nbt, position +FROM instance_servers; + +DROP TABLE instance_servers; +ALTER TABLE instance_servers_new RENAME TO instance_servers; diff --git a/packages/app-lib/migrations/20261003000200_synced-pack-selection.sql b/packages/app-lib/migrations/20261003000200_synced-pack-selection.sql new file mode 100644 index 0000000..b4dd9a0 --- /dev/null +++ b/packages/app-lib/migrations/20261003000200_synced-pack-selection.sql @@ -0,0 +1,2 @@ +ALTER TABLE synced_pack_catalog ADD COLUMN selected INTEGER NOT NULL DEFAULT 1 CHECK (selected IN (0, 1)); +ALTER TABLE synced_pack_catalog ADD COLUMN selection_order INTEGER; diff --git a/packages/app-lib/migrations/20261003000300_disable-discovered-game-options.sql b/packages/app-lib/migrations/20261003000300_disable-discovered-game-options.sql new file mode 100644 index 0000000..7bc65a7 --- /dev/null +++ b/packages/app-lib/migrations/20261003000300_disable-discovered-game-options.sql @@ -0,0 +1,5 @@ +-- Discovered options are catalogued for review, but must not be synced until +-- the user explicitly enables them. +UPDATE synced_game_option_preferences +SET enabled = 0 +WHERE source = 'discovery_default' AND enabled = 1; diff --git a/packages/app-lib/src/install/model.rs b/packages/app-lib/src/install/model.rs index 847c8d6..1e7f632 100644 --- a/packages/app-lib/src/install/model.rs +++ b/packages/app-lib/src/install/model.rs @@ -394,6 +394,7 @@ mod tests { groups: Vec::new(), launch_overrides: InstanceLaunchOverrides::empty(instance_id), loader_components: Vec::new(), + synced_options: Default::default(), }, install_stage: InstanceInstallStage::Installed, content: None, diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index 411a5c5..573b716 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -1834,3 +1834,31 @@ mod tests { assert_eq!(launch.applied_content_set.revision, 5); } } + +pub(crate) async fn get_instance_synced_options( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result { + let rows = sqlx::query( + "SELECT feature, enabled FROM instance_sync_preferences WHERE instance_id = ?", + ) + .bind(instance_id) + .fetch_all(pool) + .await?; + let mut options = crate::state::InstanceSyncedOptions::default(); + for row in rows { + let feature: String = sqlx::Row::get(&row, "feature"); + let enabled: bool = sqlx::Row::get(&row, "enabled"); + match feature.as_str() { + "game_options" => options.game_options = enabled, + "command_history" => options.command_history = enabled, + "multiplayer_servers" => options.multiplayer_servers = enabled, + "creative_hotbars" => options.creative_hotbars = enabled, + "screenshots" => options.screenshots = enabled, + "resource_packs" => options.resource_packs = enabled, + "data_packs" => options.data_packs = enabled, + _ => {} + } + } + Ok(options) +} diff --git a/packages/app-lib/src/state/instances/commands/get_instance.rs b/packages/app-lib/src/state/instances/commands/get_instance.rs index e8a9b36..24ce226 100644 --- a/packages/app-lib/src/state/instances/commands/get_instance.rs +++ b/packages/app-lib/src/state/instances/commands/get_instance.rs @@ -15,6 +15,8 @@ pub struct InstanceMetadata { pub launch_overrides: InstanceLaunchOverrides, #[serde(default)] pub loader_components: Vec, + #[serde(default)] + pub synced_options: crate::state::InstanceSyncedOptions, } pub(crate) async fn get_instance( @@ -36,10 +38,10 @@ pub(crate) async fn get_instance_metadata( let loader_components = loader_component_rows::list_loader_components(instance_id, pool) .await?; - Ok(Some(InstanceMetadata::from_record( - record, - loader_components, - ))) + let mut metadata = InstanceMetadata::from_record(record, loader_components); + metadata.synced_options = + instance_rows::get_instance_synced_options(instance_id, pool).await?; + Ok(Some(metadata)) } pub(crate) async fn get_instances_metadata( @@ -55,7 +57,11 @@ pub(crate) async fn get_instances_metadata( pool, ) .await?; - metadata.push(InstanceMetadata::from_record(record, components)); + let mut item = InstanceMetadata::from_record(record, components); + item.synced_options = + instance_rows::get_instance_synced_options(&item.instance.id, pool) + .await?; + metadata.push(item); } Ok(metadata) } @@ -71,7 +77,11 @@ pub(crate) async fn list_instances( pool, ) .await?; - metadata.push(InstanceMetadata::from_record(record, components)); + let mut item = InstanceMetadata::from_record(record, components); + item.synced_options = + instance_rows::get_instance_synced_options(&item.instance.id, pool) + .await?; + metadata.push(item); } Ok(metadata) } @@ -88,6 +98,7 @@ impl InstanceMetadata { groups: record.groups, launch_overrides: record.launch_overrides, loader_components, + synced_options: Default::default(), } } } diff --git a/packages/app-lib/src/state/instances/config_sync.rs b/packages/app-lib/src/state/instances/config_sync.rs index ff525da..8f26269 100644 --- a/packages/app-lib/src/state/instances/config_sync.rs +++ b/packages/app-lib/src/state/instances/config_sync.rs @@ -468,6 +468,7 @@ mod tests { groups: vec!["Group A".to_string()], launch_overrides, loader_components: Vec::new(), + synced_options: Default::default(), } } diff --git a/packages/app-lib/src/state/instances/model/instance.rs b/packages/app-lib/src/state/instances/model/instance.rs index 1c448dd..1e392f9 100644 --- a/packages/app-lib/src/state/instances/model/instance.rs +++ b/packages/app-lib/src/state/instances/model/instance.rs @@ -88,3 +88,24 @@ pub(crate) fn playtime_to_storage( .into() }) } + +/// Per-instance synchronization feature switches. Mirrors the Axolotl +/// `InstanceSyncedOptions` shape so the sync subsystem can be ported on top of +/// the shared `instance_sync_preferences` table. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct InstanceSyncedOptions { + #[serde(default)] + pub game_options: bool, + #[serde(default)] + pub command_history: bool, + #[serde(default)] + pub multiplayer_servers: bool, + #[serde(default)] + pub creative_hotbars: bool, + #[serde(default)] + pub screenshots: bool, + #[serde(default)] + pub resource_packs: bool, + #[serde(default)] + pub data_packs: bool, +}