feat: 跨实例同步移植 local_settings_editor(2c 收尾)
Sync source to CNB / Sync Git ref (push) Has been cancelled

- local_settings_editor.rs:单实例(未同步)游戏设置的读取/预览/保存

- game_options/mod.rs 补声明与 re-export

- api/instance/mod.rs 补 game_options 的公开导出(get_local_game_options_config 等)
This commit is contained in:
AxTps committed 2026-10-04 08:28:03 +08:00
1 parent b7c16594ce
commit 121da431ed
3 files changed
+435

No files matched your search

+12
View File
@@ -123,6 +123,18 @@ pub use self::screenshots::{
list_all_screenshots, list_screenshots, list_synced_screenshots,
move_screenshots, save_edited_screenshot,
};
pub use self::synced_options::game_options::{
GameOptionsSourceCandidate, GameSettingLocaleLabels,
GameSettingsEditorState, SaveGameSettingsResult, UpdateGameSettingsRequest,
get_config as get_synced_game_options_config,
get_game_setting_locale_labels,
get_local_config as get_local_game_options_config,
list_sync_sources as list_game_options_sync_sources,
preview_changes as preview_synced_game_option_changes,
preview_local_changes as preview_local_game_option_changes,
save_changes as save_synced_game_option_changes,
save_local_changes as save_local_game_option_changes,
};
pub(crate) use self::synced_options::game_options::{
apply_launcher_overrides as apply_game_options_launcher_overrides,
sync_before_launch as sync_game_options_before_launch,
@@ -0,0 +1,418 @@
//! Loads and saves the game settings stored by one unsynced instance.
use super::super as synced_options;
use super::CATALOG_REVISION;
use super::api_types::*;
use super::catalog::*;
use super::launch_overrides::currently_launcher_owned_keys;
use super::options_file::{
GameOptionsDocument, input_error, options_path, read_document, sha1_bytes,
validate_raw_key_value,
};
use super::read_instance_changes::custom_setting_id;
use crate::state::{CanonicalValue, GameOptionKind, InstanceMetadata, State};
use crate::util::io;
use std::collections::HashSet;
fn compatibility(controlled: bool) -> GameOptionCompatibility {
if controlled {
GameOptionCompatibility {
total_participating: 1,
will_receive: 0,
write_now: 0,
left_local: 1,
buckets: vec![GameOptionCompatibilityBucket {
instance_count: 1,
write_keys: Vec::new(),
eventual_keys: Vec::new(),
game_versions: Vec::new(),
status: GameOptionCompatibilityStatus::Controlled,
mapping: None,
reason: Some(GameOptionCompatibilityReason::LauncherControlled),
}],
}
} else {
GameOptionCompatibility {
total_participating: 1,
will_receive: 1,
write_now: 1,
left_local: 0,
buckets: Vec::new(),
}
}
}
fn categories(settings: &[EditableGameSetting]) -> Vec<GameSettingCategory> {
[
"skin_customization",
"video",
"language",
"music_and_sound",
"controls",
"chat",
"accessibility",
"online",
"custom",
]
.into_iter()
.filter(|id| settings.iter().any(|setting| setting.category_id == *id))
.map(|id| GameSettingCategory {
id: id.to_string(),
is_custom: id == "custom",
})
.collect()
}
fn editor_revision(metadata: &InstanceMetadata, input_bytes: &[u8]) -> String {
sha1_bytes(
format!(
"{CATALOG_REVISION}:{}:{}",
metadata.applied_content_set.game_version,
sha1_bytes(input_bytes)
)
.as_bytes(),
)
}
async fn load_instance_document(
instance_id: &str,
state: &State,
) -> crate::Result<(
InstanceMetadata,
GameOptionsDocument,
Vec<u8>,
HashSet<String>,
)> {
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| input_error("Unknown instance"))?;
if crate::state::game_options_sync_is_enabled(&state.pool).await?
&& metadata.synced_options.game_options
{
return Err(input_error(
"This instance is using synced game settings.",
));
}
if synced_options::sync_files_are_protected(&metadata) {
return Err(input_error(
"Game settings cannot be edited while this instance is installing or updating.",
));
}
let path = options_path(&metadata, state)?;
if !path.exists() {
return Err(input_error(
"Launch this instance once so Minecraft can create options.txt.",
));
}
let (document, input_bytes) = read_document(&path).await?;
let mut controlled_keys = currently_launcher_owned_keys(&metadata);
if metadata.launch_overrides.force_fullscreen.is_none()
&& crate::state::Settings::get(&state.pool)
.await?
.force_fullscreen
{
controlled_keys.insert("fullscreen".to_string());
}
Ok((metadata, document, input_bytes, controlled_keys))
}
fn build_editor_state(
metadata: &InstanceMetadata,
document: &GameOptionsDocument,
input_bytes: &[u8],
controlled_keys: &HashSet<String>,
) -> GameSettingsEditorState {
let game_version = metadata.applied_content_set.game_version.as_str();
let mut settings = Vec::new();
if supported_settings_cover_game_version(game_version) {
for definition in all_supported_settings() {
let Some(physical_key) =
observed_physical_key(definition, document, game_version)
else {
continue;
};
let Some(value) = document.value(&physical_key).and_then(|raw| {
decode_value_for_version(
definition,
&physical_key,
raw,
game_version,
)
}) else {
continue;
};
let controlled = controlled_keys.contains(definition.id);
settings.push(EditableGameSetting {
option_id: definition.id.to_string(),
category_id: definition.category.to_string(),
kind: GameOptionKind::Vanilla,
raw_key: None,
sync_enabled: true,
canonical_value: Some(value),
value_state: GameOptionValueState::Canonical,
option_revision: 0,
editor: editor_for(definition),
compatibility: compatibility(controlled),
validation_error: None,
controlled,
});
}
}
let mut custom_settings = document
.effective_entries()
.into_iter()
.filter_map(|(key, (index, _))| {
if controlled_keys.contains(key)
|| setting_by_file_key(key).is_some()
|| is_never_sync_key(key)
{
return None;
}
let entry = document.lines[index].entry.as_ref()?;
validate_raw_key_value(key, &entry.value).ok()?;
Some(EditableGameSetting {
option_id: custom_setting_id(key),
category_id: "custom".to_string(),
kind: GameOptionKind::External,
raw_key: Some(key.to_string()),
sync_enabled: true,
canonical_value: Some(CanonicalValue::ExternalRaw(
entry.value.clone(),
)),
value_state: GameOptionValueState::Canonical,
option_revision: 0,
editor: custom_setting_editor(),
compatibility: compatibility(false),
validation_error: None,
controlled: false,
})
})
.collect::<Vec<_>>();
custom_settings.sort_by(|left, right| left.raw_key.cmp(&right.raw_key));
settings.extend(custom_settings);
GameSettingsEditorState {
summary_revision: editor_revision(metadata, input_bytes),
canonical_revision: 0,
catalog_revision: CATALOG_REVISION,
total_participating: 1,
categories: categories(&settings),
settings,
}
}
fn encode_setting_value(
metadata: &InstanceMetadata,
document: &GameOptionsDocument,
setting: &EditableGameSetting,
value: &CanonicalValue,
) -> crate::Result<(String, String)> {
let game_version = metadata.applied_content_set.game_version.as_str();
if let Some(definition) = setting_by_id(&setting.option_id) {
validate_canonical_value(Some(definition), value)?;
let key = observed_physical_key(definition, document, game_version)
.ok_or_else(|| {
input_error(format!(
"{} is no longer present in options.txt",
setting.option_id
))
})?;
let raw = encode_value(
definition,
&key,
value,
game_version,
document.value(&key),
)
.ok_or_else(|| {
input_error(format!(
"{} cannot be represented by this Minecraft version",
setting.option_id
))
})?;
Ok((key, raw))
} else {
validate_canonical_value(None, value)?;
let key = setting.raw_key.clone().ok_or_else(|| {
input_error("Custom setting is missing its file key")
})?;
let CanonicalValue::ExternalRaw(raw) = value else {
return Err(input_error(
"Custom settings require a raw string value",
));
};
validate_raw_key_value(&key, raw)?;
Ok((key, raw.clone()))
}
}
async fn load_editor(
instance_id: &str,
state: &State,
) -> crate::Result<GameSettingsEditorState> {
let (metadata, document, input_bytes, controlled_keys) =
load_instance_document(instance_id, state).await?;
Ok(build_editor_state(
&metadata,
&document,
&input_bytes,
&controlled_keys,
))
}
pub async fn get_config(
instance_id: &str,
) -> crate::Result<GameSettingsEditorState> {
let state = State::get().await?;
let _guard = state.lock_synced_options().await;
load_editor(instance_id, &state).await
}
pub async fn preview_changes(
instance_id: &str,
request: UpdateGameSettingsRequest,
) -> crate::Result<GameSettingsEditorState> {
let state = State::get().await?;
let _guard = state.lock_synced_options().await;
let (metadata, document, input_bytes, controlled_keys) =
load_instance_document(instance_id, &state).await?;
let mut editor = build_editor_state(
&metadata,
&document,
&input_bytes,
&controlled_keys,
);
let stale = request.expected_catalog_revision != CATALOG_REVISION
|| request.expected_summary_revision != editor.summary_revision
|| request.expected_canonical_revision != editor.canonical_revision;
for change in request.changes {
let Some(setting) = editor
.settings
.iter_mut()
.find(|setting| setting.option_id == change.option_id)
else {
continue;
};
if stale || change.base_option_revision != setting.option_revision {
setting.validation_error =
Some(GameOptionValidationIssue::ChangedSinceOpened);
continue;
}
if change.sync_enabled.is_some() {
setting.validation_error =
Some(GameOptionValidationIssue::InvalidValue);
continue;
}
if let Some(value) = change.canonical_value {
match value {
Some(value)
if encode_setting_value(
&metadata, &document, setting, &value,
)
.is_ok() =>
{
setting.canonical_value = Some(value);
setting.value_state = GameOptionValueState::Canonical;
setting.validation_error = None;
}
_ => {
setting.canonical_value = None;
setting.value_state = GameOptionValueState::Invalid;
setting.validation_error =
Some(GameOptionValidationIssue::InvalidValue);
}
}
}
}
Ok(editor)
}
pub async fn save_changes(
instance_id: &str,
request: UpdateGameSettingsRequest,
) -> crate::Result<SaveGameSettingsResult> {
let state = State::get().await?;
let _guard = state.lock_synced_options().await;
let (metadata, mut document, input_bytes, controlled_keys) =
load_instance_document(instance_id, &state).await?;
let current_editor = build_editor_state(
&metadata,
&document,
&input_bytes,
&controlled_keys,
);
let UpdateGameSettingsRequest {
expected_summary_revision,
expected_canonical_revision,
expected_catalog_revision,
changes,
} = request;
if expected_catalog_revision != CATALOG_REVISION
|| expected_summary_revision != current_editor.summary_revision
|| expected_canonical_revision != current_editor.canonical_revision
{
return Ok(SaveGameSettingsResult {
state: Some(current_editor),
conflicts: changes
.into_iter()
.map(|change| change.option_id)
.collect(),
..SaveGameSettingsResult::default()
});
}
let mut changed = false;
for change in changes {
let setting = current_editor
.settings
.iter()
.find(|setting| setting.option_id == change.option_id)
.ok_or_else(|| {
input_error(format!(
"Unknown game setting {}",
change.option_id
))
})?;
if setting.controlled {
return Err(input_error(format!(
"{} is controlled by the instance's launcher settings",
setting.option_id
)));
}
if change.base_option_revision != setting.option_revision {
return Ok(SaveGameSettingsResult {
state: Some(current_editor),
conflicts: vec![change.option_id],
..SaveGameSettingsResult::default()
});
}
if change.sync_enabled.is_some() {
return Err(input_error(
"Sync selection cannot be changed in the instance editor",
));
}
let Some(Some(value)) = change.canonical_value else {
return Err(input_error(format!(
"Choose a value for {}",
setting.option_id
)));
};
let (key, raw) =
encode_setting_value(&metadata, &document, setting, &value)?;
changed |= document.set(&key, &raw, false)?;
}
if changed {
io::write(options_path(&metadata, &state)?, document.serialize()?)
.await?;
}
let refreshed = load_editor(instance_id, &state).await?;
Ok(SaveGameSettingsResult {
state: Some(refreshed),
applied: u32::from(changed),
..SaveGameSettingsResult::default()
})
}
@@ -18,6 +18,7 @@ mod catalog;
mod fullscreen;
mod instance_support;
mod launch_overrides;
mod local_settings_editor;
pub(crate) mod locales;
mod options_file;
mod pack_updates;
@@ -39,6 +40,10 @@ pub use api_types::{
UpdateGameSettingsRequest,
};
pub use launch_overrides::{apply_launcher_overrides, sync_before_launch};
pub use local_settings_editor::{
get_config as get_local_config, preview_changes as preview_local_changes,
save_changes as save_local_changes,
};
pub(crate) use locales::{
GameLocaleIndexer, queue_game_locale_index, start_game_locale_indexer,
};