diff --git a/_typos.toml b/_typos.toml index 72c15cc..08c93e0 100644 --- a/_typos.toml +++ b/_typos.toml @@ -14,6 +14,8 @@ extend-exclude = [ "packages/app-lib/src/api/content_search/WikiEntries.txt", # Bundled Monaco editor runtime contains upstream generated assets. "apps/app-frontend/public/monaco/", + # Minecraft language-name data (from mcmeta); contains language codes that look like typos. + "apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/languages.ts", ] [default.extend-words] diff --git a/apps/app-frontend/src/components/ui/instance_settings/SyncSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/SyncSettings.vue new file mode 100644 index 0000000..afd121f --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance_settings/SyncSettings.vue @@ -0,0 +1,618 @@ + + + diff --git a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue index d7fa727..8573ea4 100644 --- a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue @@ -7,6 +7,7 @@ import { FileArchiveIcon, InfoIcon, MonitorIcon, + RefreshCwIcon, WrenchIcon, } from '@modrinth/assets' import { @@ -23,6 +24,7 @@ import { computed, nextTick, ref, watch } from 'vue' import BackupSettings from '@/components/ui/instance_settings/BackupSettings.vue' import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue' import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue' +import SyncSettings from '@/components/ui/instance_settings/SyncSettings.vue' import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue' import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue' import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue' @@ -53,8 +55,9 @@ let serverMetadataGeneration = 0 const instanceRef = computed(() => props.instance) const tabbedModal = ref | null>(null) -function hide() { +function hide(onAfterClose?: () => void) { tabbedModal.value?.hide() + if (onAfterClose) nextTick(onAfterClose) } provideInstanceSettings({ @@ -147,6 +150,14 @@ const tabs = computed(() => [ icon: CodeIcon, content: HooksSettings, }, + { + name: defineMessage({ + id: 'instance.settings.tabs.sync', + defaultMessage: 'Sync', + }), + icon: RefreshCwIcon, + content: SyncSettings, + }, ]) function getSupportedModpackLoaders() { diff --git a/apps/app-frontend/src/components/ui/settings/instances/SyncSourceModal.vue b/apps/app-frontend/src/components/ui/settings/instances/SyncSourceModal.vue new file mode 100644 index 0000000..c975cbf --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/SyncSourceModal.vue @@ -0,0 +1,181 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/SyncedPacksModal.vue b/apps/app-frontend/src/components/ui/settings/instances/SyncedPacksModal.vue new file mode 100644 index 0000000..9298660 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/SyncedPacksModal.vue @@ -0,0 +1,81 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/command-history-modal.vue b/apps/app-frontend/src/components/ui/settings/instances/command-history-modal.vue new file mode 100644 index 0000000..222c478 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/command-history-modal.vue @@ -0,0 +1,93 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/boolean-control.vue b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/boolean-control.vue new file mode 100644 index 0000000..009aaff --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/boolean-control.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/editors.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/editors.ts new file mode 100644 index 0000000..16908ab --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/editors.ts @@ -0,0 +1,163 @@ +import { toRaw } from 'vue' + +import type { + EditableGameSetting, + GameOptionCanonicalValue, + GameSettingChange, + GameSettingsEditorState, +} from '@/helpers/game-options' + +function clonePlainValue(value: unknown): unknown { + if (Array.isArray(value)) { + return toRaw(value).map(clonePlainValue) + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(toRaw(value)).map(([key, nestedValue]) => [key, clonePlainValue(nestedValue)]), + ) + } + return value +} + +export function cloneGameSettingsState(state: GameSettingsEditorState): GameSettingsEditorState { + return clonePlainValue(state) as GameSettingsEditorState +} + +export function canonicalValuesEqual( + left: GameOptionCanonicalValue | null | undefined, + right: GameOptionCanonicalValue | null | undefined, +): boolean { + return JSON.stringify(left ?? null) === JSON.stringify(right ?? null) +} + +export function gameSettingChanges( + base: GameSettingsEditorState | null, + draft: GameSettingsEditorState | null, + forcedValueOptionIds: ReadonlySet = new Set(), +): GameSettingChange[] { + if (!base || !draft) return [] + + const baseSettings = new Map(base.settings.map((setting) => [setting.option_id, setting])) + return draft.settings.flatMap((setting) => { + const previous = baseSettings.get(setting.option_id) + if (!previous) return [] + + const syncChanged = previous.sync_enabled !== setting.sync_enabled + const valueChanged = !canonicalValuesEqual(previous.canonical_value, setting.canonical_value) + const promoteLocalValue = + forcedValueOptionIds.has(setting.option_id) && + previous.value_state !== 'canonical' && + setting.canonical_value !== null && + setting.canonical_value !== undefined + if (!syncChanged && !valueChanged && !promoteLocalValue) return [] + + return [ + { + option_id: setting.option_id, + base_option_revision: previous.option_revision, + ...(syncChanged ? { sync_enabled: setting.sync_enabled } : {}), + ...(valueChanged || promoteLocalValue + ? { canonical_value: setting.canonical_value ?? null } + : {}), + }, + ] + }) +} + +export function gameSettingNumberScale(setting: EditableGameSetting): number { + if (setting.editor.unit !== 'percent') return 1 + return setting.option_id === 'sensitivity' ? 200 : 100 +} + +export function canonicalValueText(setting: EditableGameSetting): string { + const value = setting.canonical_value + if (!value) return '' + + switch (value.type) { + case 'bool': + return value.value ? 'true' : 'false' + case 'integer': + case 'decimal': + return setting.editor.unit === 'percent' + ? String(Number((Number(value.value) * gameSettingNumberScale(setting)).toFixed(8))) + : String(value.value) + case 'string_list': + return value.value.join(', ') + default: + return value.value + } +} + +export function canonicalBooleanValue(setting: EditableGameSetting): boolean | undefined { + return setting.canonical_value?.type === 'bool' ? setting.canonical_value.value : undefined +} + +export function isKeybindSetting(setting: EditableGameSetting): boolean { + return ( + setting.editor.type === 'key_binding' || + (setting.editor.type === 'external_raw' && !!setting.raw_key?.startsWith('key_')) + ) +} + +export function canonicalValueFromInput( + setting: EditableGameSetting, + value: string | number | boolean | undefined, +): GameOptionCanonicalValue | null { + if (value === undefined || value === '') return null + + switch (setting.editor.type) { + case 'bool': + return { type: 'bool', value: Boolean(value) } + case 'integer': { + const parsed = Number(value) + return Number.isSafeInteger(parsed) ? { type: 'integer', value: parsed } : null + } + case 'decimal': { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return null + return { + type: 'decimal', + value: + setting.editor.unit === 'percent' + ? String(Number((parsed / gameSettingNumberScale(setting)).toFixed(8))) + : String(value), + } + } + case 'enum': + return { type: 'enum', value: String(value) } + case 'string_list': + return { + type: 'string_list', + value: String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + } + case 'key_binding': + return { type: 'key_binding', value: String(value) } + case 'external_raw': + return { type: 'external_raw', value: String(value) } + default: + return { type: 'text', value: String(value) } + } +} + +export function settingSearchText( + setting: EditableGameSetting, + label: string, + description: string, +): string { + return [label, description, setting.option_id, setting.raw_key] + .filter(Boolean) + .join(' ') + .toLocaleLowerCase() +} + +export function settingCanBeEnabled(setting: EditableGameSetting): boolean { + return ( + !setting.controlled && + !setting.validation_error && + !['mixed', 'unset', 'invalid'].includes(setting.value_state) && + (setting.compatibility.total_participating === 0 || setting.compatibility.will_receive > 0) + ) +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/index.vue b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/index.vue new file mode 100644 index 0000000..693c517 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/index.vue @@ -0,0 +1,483 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybind-input.vue b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybind-input.vue new file mode 100644 index 0000000..aa673b3 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybind-input.vue @@ -0,0 +1,235 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybinds.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybinds.ts new file mode 100644 index 0000000..ec64a32 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/keybinds.ts @@ -0,0 +1,426 @@ +import { defineMessages, type MessageDescriptor, type VIntlFormatters } from '@modrinth/ui' + +type FormatMessage = VIntlFormatters['formatMessage'] + +type StopKeybindRecording = () => void +let stopActiveKeybindRecording: StopKeybindRecording | null = null + +export function activateKeybindRecording(stop: StopKeybindRecording) { + if (stopActiveKeybindRecording === stop) return + const stopPrevious = stopActiveKeybindRecording + stopActiveKeybindRecording = stop + stopPrevious?.() +} + +export function deactivateKeybindRecording(stop: StopKeybindRecording) { + if (stopActiveKeybindRecording === stop) { + stopActiveKeybindRecording = null + } +} + +const keyMessages = defineMessages({ + unbound: { + id: 'app.settings.game-options.keybind.key.unbound', + defaultMessage: 'Unbound', + }, + escape: { + id: 'app.settings.game-options.keybind.key.escape', + defaultMessage: 'Escape', + }, + backspace: { + id: 'app.settings.game-options.keybind.key.backspace', + defaultMessage: 'Backspace', + }, + tab: { + id: 'app.settings.game-options.keybind.key.tab', + defaultMessage: 'Tab', + }, + enter: { + id: 'app.settings.game-options.keybind.key.enter', + defaultMessage: 'Enter', + }, + leftControl: { + id: 'app.settings.game-options.keybind.key.left-control', + defaultMessage: 'Left Ctrl', + }, + rightControl: { + id: 'app.settings.game-options.keybind.key.right-control', + defaultMessage: 'Right Ctrl', + }, + leftShift: { + id: 'app.settings.game-options.keybind.key.left-shift', + defaultMessage: 'Left Shift', + }, + rightShift: { + id: 'app.settings.game-options.keybind.key.right-shift', + defaultMessage: 'Right Shift', + }, + leftAlt: { + id: 'app.settings.game-options.keybind.key.left-alt', + defaultMessage: 'Left Alt', + }, + rightAlt: { + id: 'app.settings.game-options.keybind.key.right-alt', + defaultMessage: 'Right Alt', + }, + space: { + id: 'app.settings.game-options.keybind.key.space', + defaultMessage: 'Space', + }, + capsLock: { + id: 'app.settings.game-options.keybind.key.caps-lock', + defaultMessage: 'Caps Lock', + }, + numLock: { + id: 'app.settings.game-options.keybind.key.num-lock', + defaultMessage: 'Num Lock', + }, + scrollLock: { + id: 'app.settings.game-options.keybind.key.scroll-lock', + defaultMessage: 'Scroll Lock', + }, + printScreen: { + id: 'app.settings.game-options.keybind.key.print-screen', + defaultMessage: 'Print Screen', + }, + pause: { + id: 'app.settings.game-options.keybind.key.pause', + defaultMessage: 'Pause', + }, + home: { + id: 'app.settings.game-options.keybind.key.home', + defaultMessage: 'Home', + }, + end: { + id: 'app.settings.game-options.keybind.key.end', + defaultMessage: 'End', + }, + pageUp: { + id: 'app.settings.game-options.keybind.key.page-up', + defaultMessage: 'Page Up', + }, + pageDown: { + id: 'app.settings.game-options.keybind.key.page-down', + defaultMessage: 'Page Down', + }, + insert: { + id: 'app.settings.game-options.keybind.key.insert', + defaultMessage: 'Insert', + }, + delete: { + id: 'app.settings.game-options.keybind.key.delete', + defaultMessage: 'Delete', + }, + arrowUp: { + id: 'app.settings.game-options.keybind.key.arrow-up', + defaultMessage: 'Up Arrow', + }, + arrowDown: { + id: 'app.settings.game-options.keybind.key.arrow-down', + defaultMessage: 'Down Arrow', + }, + arrowLeft: { + id: 'app.settings.game-options.keybind.key.arrow-left', + defaultMessage: 'Left Arrow', + }, + arrowRight: { + id: 'app.settings.game-options.keybind.key.arrow-right', + defaultMessage: 'Right Arrow', + }, + leftSuper: { + id: 'app.settings.game-options.keybind.key.left-super', + defaultMessage: 'Left Super', + }, + rightSuper: { + id: 'app.settings.game-options.keybind.key.right-super', + defaultMessage: 'Right Super', + }, + leftCommand: { + id: 'app.settings.game-options.keybind.key.left-command', + defaultMessage: 'Left Command', + }, + rightCommand: { + id: 'app.settings.game-options.keybind.key.right-command', + defaultMessage: 'Right Command', + }, + menu: { + id: 'app.settings.game-options.keybind.key.menu', + defaultMessage: 'Menu', + }, + keypadKey: { + id: 'app.settings.game-options.keybind.key.keypad-key', + defaultMessage: 'Numpad {key}', + }, + leftMouse: { + id: 'app.settings.game-options.keybind.mouse.left', + defaultMessage: 'Left Mouse', + }, + rightMouse: { + id: 'app.settings.game-options.keybind.mouse.right', + defaultMessage: 'Right Mouse', + }, + middleMouse: { + id: 'app.settings.game-options.keybind.mouse.middle', + defaultMessage: 'Middle Mouse', + }, + mouseButton: { + id: 'app.settings.game-options.keybind.mouse.button', + defaultMessage: 'Mouse {button}', + }, + scancode: { + id: 'app.settings.game-options.keybind.scancode', + defaultMessage: 'Scancode {code}', + }, + unknownKey: { + id: 'app.settings.game-options.keybind.key.unknown', + defaultMessage: 'Unknown key', + }, + controlModifier: { + id: 'app.settings.game-options.keybind.modifier.control', + defaultMessage: 'Ctrl', + }, + shiftModifier: { + id: 'app.settings.game-options.keybind.modifier.shift', + defaultMessage: 'Shift', + }, + altModifier: { + id: 'app.settings.game-options.keybind.modifier.alt', + defaultMessage: 'Alt', + }, + commandModifier: { + id: 'app.settings.game-options.keybind.modifier.command', + defaultMessage: 'Command', + }, + superModifier: { + id: 'app.settings.game-options.keybind.modifier.super', + defaultMessage: 'Super', + }, +}) + +const keyboardCodeTokens: Record = { + Escape: 'escape', + Minus: 'minus', + Equal: 'equal', + Backspace: 'backspace', + Tab: 'tab', + BracketLeft: 'left.bracket', + BracketRight: 'right.bracket', + Enter: 'enter', + ControlLeft: 'left.control', + Semicolon: 'semicolon', + Quote: 'apostrophe', + Backquote: 'grave.accent', + ShiftLeft: 'left.shift', + Backslash: 'backslash', + Comma: 'comma', + Period: 'period', + Slash: 'slash', + ShiftRight: 'right.shift', + AltLeft: 'left.alt', + Space: 'space', + CapsLock: 'caps.lock', + NumLock: 'num.lock', + ScrollLock: 'scroll.lock', + NumpadSubtract: 'keypad.subtract', + NumpadAdd: 'keypad.add', + NumpadDecimal: 'keypad.decimal', + NumpadComma: 'keypad.decimal', + NumpadEqual: 'keypad.equal', + NumpadEnter: 'keypad.enter', + ControlRight: 'right.control', + NumpadDivide: 'keypad.divide', + NumpadMultiply: 'keypad.multiply', + PrintScreen: 'print.screen', + AltRight: 'right.alt', + Pause: 'pause', + Home: 'home', + ArrowUp: 'up', + PageUp: 'page.up', + ArrowLeft: 'left', + ArrowRight: 'right', + End: 'end', + ArrowDown: 'down', + PageDown: 'page.down', + Insert: 'insert', + Delete: 'delete', + MetaLeft: 'left.win', + MetaRight: 'right.win', + ContextMenu: 'menu', +} + +const keyboardLabels: Record = { + escape: keyMessages.escape, + minus: '-', + equal: '=', + backspace: keyMessages.backspace, + tab: keyMessages.tab, + 'left.bracket': '[', + 'right.bracket': ']', + enter: keyMessages.enter, + 'left.control': keyMessages.leftControl, + semicolon: ';', + apostrophe: "'", + 'grave.accent': '`', + 'left.shift': keyMessages.leftShift, + backslash: '\\', + comma: ',', + period: '.', + slash: '/', + 'right.shift': keyMessages.rightShift, + 'left.alt': keyMessages.leftAlt, + space: keyMessages.space, + 'caps.lock': keyMessages.capsLock, + 'num.lock': keyMessages.numLock, + 'scroll.lock': keyMessages.scrollLock, + 'keypad.subtract': '-', + 'keypad.add': '+', + 'keypad.decimal': '.', + 'keypad.equal': '=', + 'keypad.enter': keyMessages.enter, + 'right.control': keyMessages.rightControl, + 'keypad.divide': '/', + 'keypad.multiply': '*', + 'print.screen': keyMessages.printScreen, + 'right.alt': keyMessages.rightAlt, + pause: keyMessages.pause, + home: keyMessages.home, + up: keyMessages.arrowUp, + 'page.up': keyMessages.pageUp, + left: keyMessages.arrowLeft, + right: keyMessages.arrowRight, + end: keyMessages.end, + down: keyMessages.arrowDown, + 'page.down': keyMessages.pageDown, + insert: keyMessages.insert, + delete: keyMessages.delete, + menu: keyMessages.menu, +} + +function splitKeybind(value: string): [key: string, modifier?: string] { + const separator = value.indexOf(':') + return separator === -1 ? [value] : [value.slice(0, separator), value.slice(separator + 1)] +} + +function keyboardIdentifierFromCode(code: string): string | null { + const letter = /^Key([A-Z])$/.exec(code) + if (letter) return letter[1].toLowerCase() + + const digit = /^Digit([0-9])$/.exec(code) + if (digit) return digit[1] + + const functionKey = /^F([1-9]|1[0-9]|2[0-5])$/.exec(code) + if (functionKey) return `f${functionKey[1]}` + + const keypadDigit = /^Numpad([0-9])$/.exec(code) + if (keypadDigit) return `keypad.${keypadDigit[1]}` + + return keyboardCodeTokens[code] ?? null +} + +export function minecraftKeyTokenFromKeyboardEvent(event: KeyboardEvent): string | null { + const identifier = keyboardIdentifierFromCode(event.code) + return identifier ? `key.keyboard.${identifier}` : null +} + +export function minecraftMouseTokenFromButton(button: number): string | null { + if (!Number.isInteger(button) || button < 0 || button > 15) return null + if (button === 0) return 'key.mouse.left' + if (button === 1) return 'key.mouse.middle' + if (button === 2) return 'key.mouse.right' + return `key.mouse.${button + 1}` +} + +function formatKeyboardLabel( + formatMessage: FormatMessage, + identifier: string, + isMac: boolean, +): string { + if (identifier === 'unknown') return formatMessage(keyMessages.unbound) + if (/^[a-z]$/.test(identifier)) return identifier.toUpperCase() + if (/^[0-9]$/.test(identifier)) return identifier + if (/^f([1-9]|1[0-9]|2[0-5])$/.test(identifier)) return identifier.toUpperCase() + + const keypad = /^keypad\.(.+)$/.exec(identifier) + if (keypad) { + const label = keyboardLabels[identifier] + const key = typeof label === 'string' ? label : label ? formatMessage(label) : keypad[1] + return formatMessage(keyMessages.keypadKey, { key }) + } + + if (identifier === 'left.win') { + return formatMessage(isMac ? keyMessages.leftCommand : keyMessages.leftSuper) + } + if (identifier === 'right.win') { + return formatMessage(isMac ? keyMessages.rightCommand : keyMessages.rightSuper) + } + + const label = keyboardLabels[identifier] + if (typeof label === 'string') return label + if (label) return formatMessage(label) + return identifier + .split('.') + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' ') +} + +function formatModifier(formatMessage: FormatMessage, modifier: string, isMac: boolean): string { + switch (modifier.toUpperCase()) { + case 'CONTROL': + case 'CTRL': + return formatMessage(keyMessages.controlModifier) + case 'SHIFT': + return formatMessage(keyMessages.shiftModifier) + case 'ALT': + return formatMessage(keyMessages.altModifier) + case 'META': + case 'SUPER': + return formatMessage(isMac ? keyMessages.commandModifier : keyMessages.superModifier) + default: + return modifier + } +} + +export function formatMinecraftKeybind( + formatMessage: FormatMessage, + value: string, + isMac: boolean, +): string { + const [key, modifier] = splitKeybind(value) + let label: string + + if (key.startsWith('key.keyboard.')) { + label = formatKeyboardLabel(formatMessage, key.slice('key.keyboard.'.length), isMac) + } else if (key === 'key.mouse.left') { + label = formatMessage(keyMessages.leftMouse) + } else if (key === 'key.mouse.right') { + label = formatMessage(keyMessages.rightMouse) + } else if (key === 'key.mouse.middle') { + label = formatMessage(keyMessages.middleMouse) + } else if (key.startsWith('key.mouse.')) { + label = formatMessage(keyMessages.mouseButton, { + button: key.slice('key.mouse.'.length), + }) + } else if (key.startsWith('scancode.')) { + label = formatMessage(keyMessages.scancode, { code: key.slice('scancode.'.length) }) + } else { + label = formatMessage(keyMessages.unknownKey) + } + + if (!modifier || key === 'key.keyboard.unknown') return label + const modifiers = modifier + .split(/[+:]/) + .filter(Boolean) + .map((part) => formatModifier(formatMessage, part, isMac)) + return [...modifiers, label].join(' + ') +} + +export function minecraftKeybindConflictKey(optionId: string, value: string): string | null { + const normalized = value.trim().toLowerCase() + if (!normalized || normalized === 'key.keyboard.unknown') return null + + if (optionId === 'key.debug.modifier') return null + if (optionId.startsWith('key.debug.') && optionId !== 'key.debug.overlay') { + return `debug:${normalized}` + } + return `direct:${normalized}` +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/languages.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/languages.ts new file mode 100644 index 0000000..752e3aa --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/languages.ts @@ -0,0 +1,152 @@ +import type { ComboboxOption } from '@modrinth/ui' + +/** Java Edition language names and regions from https://github.com/misode/mcmeta/blob/assets/pack.mcmeta. */ +const languageNames: Record = { + en_us: 'English (US)', + af_za: 'Afrikaans (Suid-Afrika)', + esan: 'Andalûh (Andaluçía)', + enp: 'Anglish (Oned Riches)', + ast_es: 'Asturianu (Asturies)', + az_az: 'Azərbaycanca (Azərbaycan)', + id_id: 'Bahasa Indonesia (Indonesia)', + ms_my: 'Bahasa Melayu (Malaysia)', + tzo_mx: "Bats'i k'op (Jobel)", + qid: 'Bhs. Indonesia edjaän lama (Indonesia tempo doeloe)', + be_latn: 'Biełaruskaja (Biełaruś)', + bar: 'Boarisch (Bayern)', + bs_ba: 'Bosanski (Bosna i Hercegovina)', + brb: 'Braobans (Braobant)', + br_fr: 'Brezhoneg (Breizh)', + ca_es: 'Català (Catalunya)', + val_es: 'Català (Valencià) (País Valencià)', + cy_gb: 'Cymraeg (Cymru)', + qcb_es: 'Cántabru/Montañés (Cantabria)', + da_dk: 'Dansk (Danmark)', + se_no: 'Davvisámegiella (Sápmi)', + de_at: 'Deitsch (Österreich)', + de_de: 'Deutsch (Deutschland)', + et_ee: 'Eesti keel (Eesti)', + en_au: 'English (Australia)', + en_ca: 'English (Canada)', + en_nz: 'English (New Zealand)', + en_gb: 'English (United Kingdom)', + es_ar: 'Español (Argentina)', + es_cl: 'Español (Chile)', + es_ec: 'Español (Ecuador)', + es_es: 'Español (España)', + es_mx: 'Español (México)', + es_uy: 'Español (Uruguay)', + es_ve: 'Español (Venezuela)', + eo_uy: 'Esperanto (Esperantujo)', + eu_es: 'Euskara (Euskal Herria)', + fil_ph: 'Filipino (Pilipinas)', + fr_ca: 'Français (Canada)', + fr_fr: 'Français (France)', + fr_ch: 'Français (Suisse)', + fy_nl: 'Frysk (Fryslân)', + fra_de: 'Fränggisch (Franggn)', + fur_it: 'Furlan (Friûl)', + fo_fo: 'Føroyskt (Føroyar)', + ga_ie: 'Gaeilge (Éire)', + gl_es: 'Galego (Galicia / Galiza)', + go_fr: 'Galo (Bertègn)', + gd_gb: 'Gàidhlig (Alba)', + hr_hr: 'Hrvatski (Hrvatska)', + hn_no: 'Høgnorsk (Norig)', + io_en: 'Ido (Idia)', + ig_ng: 'Igbo (Naigeria)', + it_it: 'Italiano (Italia)', + kw_gb: 'Kernewek (Kernow)', + ksh: 'Kölsch/Ripoarisch (Rhingland)', + lol_us: 'LOLCAT (Kingdom of Cats)', + la_la: 'Latina (Latium)', + lv_lv: 'Latviešu (Latvija)', + lt_lt: 'Lietuvių (Lietuva)', + li_li: 'Limburgs (Limburg)', + lmo: 'Lombard (Lombardia)', + lb_lu: 'Lëtzebuergesch (Lëtzebuerg)', + hu_hu: 'Magyar (Magyarország)', + mt_mt: 'Malti (Malta)', + isv: 'Medžuslovjansky (Slovjanščina)', + nah: 'Mēxikatlahtōlli (Mēxiko)', + nl_nl: 'Nederlands (Nederland)', + pls: 'Ngiiwà (Ndanìꞌngà)', + no_no: 'Norsk bokmål (Norge)', + nn_no: 'Norsk nynorsk (Noreg)', + uz_uz: "O'zbekcha (O'zbekiston)", + oc_fr: 'Occitan (Occitània)', + en_pt: 'Pirate Speak (The Seven Seas)', + nds_de: 'Plattdüütsch (Düütschland)', + pl_pl: 'Polski (Polska)', + pt_br: 'Português (Brasil)', + pt_pt: 'Português (Portugal)', + qya_aa: 'Quenya (Arda)', + ro_ro: 'Română (România)', + de_ch: 'Schwiizerdütsch (Schwiiz)', + enws: 'Shakespearean English (Kingdom of England)', + sq_al: 'Shqip (Shqipëri)', + sk_sk: 'Slovenčina (Slovensko)', + sl_si: 'Slovenščina (Slovenija)', + so_so: 'Soomaali (Soomaaliya)', + sr_cs: 'Srpski (Srbija)', + fi_fi: 'Suomi (Suomi)', + sv_se: 'Svenska (Sverige)', + sxu: 'Säggs’sch (Saggsn)', + tl_ph: 'Tagalog (Pilipinas)', + vi_vn: 'Tiếng Việt (Việt Nam)', + tr_tr: 'Türkçe (Türkiye)', + vp_vl: 'Viossa (Vilant)', + nl_be: 'Vlaams (België)', + vec_it: 'Vèneto (Veneto)', + vro: 'Võro (Eesti)', + yo_ng: 'Yorùbá (Nàìjíríà)', + jbo_en: "la .lojban. (la jbogu'e)", + tlh_aa: "tlhIngan Hol (tlhIngan wo')", + tok: 'toki pona (kulupu pona)', + is_is: 'Íslenska (Ísland)', + ovd: 'Övdalska (Swerre)', + cs_cz: 'Čeština (Česko)', + szl: 'Ślōnski (Gōrny Ślōnsk)', + en_ud: 'ɥsᴉꞁᵷuƎ (uʍoᗡ ǝpᴉsd∩)', + haw_us: 'ʻŌlelo Hawaiʻi (Hawaiʻi)', + el_gr: 'Ελληνικά (Ελλάδα)', + ba_ru: 'Башҡортса (Башҡортостан, Рәсәй)', + be_by: 'Беларуская (Беларусь)', + bg_bg: 'Български (България)', + hal_ua: 'Галицка (Галичина, Вкраїна)', + ky_kg: 'Кыргызча (Кыргызстан)', + mk_mk: 'Македонски (Северна Македонија)', + mn_mn: 'Монгол (Монгол Улс)', + ry_ua: 'Руснацькый (Пудкарпатя, Украина)', + ru_ru: 'Русский (Россия)', + rpr: 'Русскій дореформенный (Россійская имперія)', + sah_sah: 'Сахалыы (Cаха Сирэ)', + sr_sp: 'Српски (Србија)', + tt_ru: 'Татарча (Татарстан, Рәсәй)', + uk_ua: 'Українська (Україна)', + cv_cu: 'Чӑвашла (Чӑваш Ен, Раҫҫей)', + kk_kz: 'Қазақша (Қазақстан)', + hy_am: 'Հայերեն (Հայաստան)', + yi_de: 'ייִדיש (אשכנזיש יידן)', + he_il: 'עברית (ישראל)', + ar_sa: 'العربية (العالم العربي)', + zlm_arab: 'بهاس ملايو (مليسيا)', + fa_ir: 'فارسی (ايران)', + hi_in: 'हिंदी (भारत)', + ta_in: 'தமிழ் (இந்தியா)', + kn_in: 'ಕನ್ನಡ (ಭಾರತ)', + th_th: 'ไทย (ประเทศไทย)', + lo_la: 'ລາວ (ປະເທດລາວ)', + ka_ge: 'ქართული (საქართველო)', + lzh: '文言 (華夏)', + ja_jp: '日本語 (日本)', + zh_cn: '简体中文 (中国大陆)', + zh_tw: '繁體中文 (台灣)', + zh_hk: '繁體中文 (香港特別行政區)', + ko_kr: '한국어 (대한민국)', + got_de: '𐌲𐌿𐍄𐍂𐌰𐌶𐌳𐌰 (𐌲𐌿𐍄𐌸𐌹𐌿𐌳𐌰)', +} + +export const minecraftLanguageOptions: ComboboxOption[] = Object.entries(languageNames) + .map(([value, label]) => ({ value, label, searchTerms: [value] })) + .sort((a, b) => a.label.localeCompare(b.label)) diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/messages.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/messages.ts new file mode 100644 index 0000000..a5e741b --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/messages.ts @@ -0,0 +1,1428 @@ +import { defineMessages, type MessageDescriptor, type VIntlFormatters } from '@modrinth/ui' + +import type { + EditableGameSetting, + GameOptionValidationError, + GameSettingCategory, +} from '@/helpers/game-options' + +type FormatMessage = VIntlFormatters['formatMessage'] + +const settingMessages = defineMessages({ + fovLabel: { id: 'app.settings.game-options.setting.fov.label', defaultMessage: 'Field of view' }, + graphicsLabel: { + id: 'app.settings.game-options.setting.graphics.label', + defaultMessage: 'Graphics', + }, + graphicsDescription: { + id: 'app.settings.game-options.setting.graphics.description', + defaultMessage: 'Controls visual quality and performance.', + }, + ambientOcclusionLabel: { + id: 'app.settings.game-options.setting.ambient-occlusion.label', + defaultMessage: 'Smooth lighting', + }, + renderDistanceLabel: { + id: 'app.settings.game-options.setting.render-distance.label', + defaultMessage: 'Render distance', + }, + simulationDistanceLabel: { + id: 'app.settings.game-options.setting.simulation-distance.label', + defaultMessage: 'Simulation distance', + }, + simulationDistanceDescription: { + id: 'app.settings.game-options.setting.simulation-distance.description', + defaultMessage: 'How far away entities update and blocks and fluids tick.', + }, + guiScaleLabel: { + id: 'app.settings.game-options.setting.gui-scale.label', + defaultMessage: 'GUI scale', + }, + guiScaleDescription: { + id: 'app.settings.game-options.setting.gui-scale.description', + defaultMessage: 'The size of the game interface and HUD.', + }, + particlesLabel: { + id: 'app.settings.game-options.setting.particles.label', + defaultMessage: 'Particles', + }, + cloudsLabel: { id: 'app.settings.game-options.setting.clouds.label', defaultMessage: 'Clouds' }, + entityShadowsLabel: { + id: 'app.settings.game-options.setting.entity-shadows.label', + defaultMessage: 'Entity shadows', + }, + viewBobbingLabel: { + id: 'app.settings.game-options.setting.view-bobbing.label', + defaultMessage: 'View bobbing', + }, + viewBobbingDescription: { + id: 'app.settings.game-options.setting.view-bobbing.description', + defaultMessage: 'Add a bobbing motion to the camera while walking.', + }, + vsyncLabel: { id: 'app.settings.game-options.setting.vsync.label', defaultMessage: 'VSync' }, + vsyncDescription: { + id: 'app.settings.game-options.setting.vsync.description', + defaultMessage: 'Limit the frame rate to the display refresh rate to prevent screen tearing.', + }, + fullscreenLabel: { + id: 'app.settings.game-options.setting.fullscreen.label', + defaultMessage: 'Fullscreen', + }, + maxFramerateLabel: { + id: 'app.settings.game-options.setting.max-framerate.label', + defaultMessage: 'Maximum framerate', + }, + mipmapLevelsLabel: { + id: 'app.settings.game-options.setting.mipmap-levels.label', + defaultMessage: 'Mipmap levels', + }, + mipmapLevelsDescription: { + id: 'app.settings.game-options.setting.mipmap-levels.description', + defaultMessage: 'Texture smoothing at a distance.', + }, + biomeBlendRadiusLabel: { + id: 'app.settings.game-options.setting.biome-blend-radius.label', + defaultMessage: 'Biome blend', + }, + biomeBlendRadiusDescription: { + id: 'app.settings.game-options.setting.biome-blend-radius.description', + defaultMessage: 'The distance over which biome colors transition.', + }, + languageLabel: { + id: 'app.settings.game-options.setting.language.label', + defaultMessage: 'Language', + }, + masterVolumeLabel: { + id: 'app.settings.game-options.setting.master-volume.label', + defaultMessage: 'Master volume', + }, + musicVolumeLabel: { + id: 'app.settings.game-options.setting.music-volume.label', + defaultMessage: 'Music', + }, + musicToastLabel: { + id: 'app.settings.game-options.setting.music-toast.label', + defaultMessage: 'Music notification', + }, + musicToastDescription: { + id: 'app.settings.game-options.setting.music-toast.description', + defaultMessage: 'Choose whether music titles appear in the pause menu and as toasts.', + }, + recordVolumeLabel: { + id: 'app.settings.game-options.setting.record-volume.label', + defaultMessage: 'Jukebox/Note Blocks', + }, + weatherVolumeLabel: { + id: 'app.settings.game-options.setting.weather-volume.label', + defaultMessage: 'Weather', + }, + blocksVolumeLabel: { + id: 'app.settings.game-options.setting.blocks-volume.label', + defaultMessage: 'Blocks', + }, + hostileVolumeLabel: { + id: 'app.settings.game-options.setting.hostile-volume.label', + defaultMessage: 'Hostile creatures', + }, + neutralVolumeLabel: { + id: 'app.settings.game-options.setting.neutral-volume.label', + defaultMessage: 'Friendly creatures', + }, + playersVolumeLabel: { + id: 'app.settings.game-options.setting.players-volume.label', + defaultMessage: 'Players', + }, + ambientVolumeLabel: { + id: 'app.settings.game-options.setting.ambient-volume.label', + defaultMessage: 'Ambient/Environment', + }, + voiceVolumeLabel: { + id: 'app.settings.game-options.setting.voice-volume.label', + defaultMessage: 'Voice and speech', + }, + uiVolumeLabel: { + id: 'app.settings.game-options.setting.ui-volume.label', + defaultMessage: 'UI', + }, + sensitivityLabel: { + id: 'app.settings.game-options.setting.sensitivity.label', + defaultMessage: 'Mouse sensitivity', + }, + invertMouseLabel: { + id: 'app.settings.game-options.setting.invert-mouse.label', + defaultMessage: 'Invert mouse', + }, + invertMouseDescription: { + id: 'app.settings.game-options.setting.invert-mouse.description', + defaultMessage: 'Invert vertical mouse movement.', + }, + autoJumpLabel: { + id: 'app.settings.game-options.setting.auto-jump.label', + defaultMessage: 'Auto-jump', + }, + autoJumpDescription: { + id: 'app.settings.game-options.setting.auto-jump.description', + defaultMessage: 'Automatically jump up one-block-high obstacles.', + }, + toggleCrouchLabel: { + id: 'app.settings.game-options.setting.toggle-crouch.label', + defaultMessage: 'Toggle crouch', + }, + toggleCrouchDescription: { + id: 'app.settings.game-options.setting.toggle-crouch.description', + defaultMessage: 'Press once to remain crouched.', + }, + toggleSprintLabel: { + id: 'app.settings.game-options.setting.toggle-sprint.label', + defaultMessage: 'Toggle sprint', + }, + toggleSprintDescription: { + id: 'app.settings.game-options.setting.toggle-sprint.description', + defaultMessage: 'Press once to remain sprinting.', + }, + discreteMouseScrollLabel: { + id: 'app.settings.game-options.setting.discrete-mouse-scroll.label', + defaultMessage: 'Discrete scrolling', + }, + discreteMouseScrollDescription: { + id: 'app.settings.game-options.setting.discrete-mouse-scroll.description', + defaultMessage: 'Treat each mouse-wheel input as a single scroll step.', + }, + keyForwardLabel: { + id: 'app.settings.game-options.setting.key-forward.label', + defaultMessage: 'Move forward', + }, + keyLeftLabel: { + id: 'app.settings.game-options.setting.key-left.label', + defaultMessage: 'Strafe left', + }, + keyBackLabel: { + id: 'app.settings.game-options.setting.key-back.label', + defaultMessage: 'Move backward', + }, + keyRightLabel: { + id: 'app.settings.game-options.setting.key-right.label', + defaultMessage: 'Strafe right', + }, + keyJumpLabel: { id: 'app.settings.game-options.setting.key-jump.label', defaultMessage: 'Jump' }, + keySneakLabel: { + id: 'app.settings.game-options.setting.key-sneak.label', + defaultMessage: 'Sneak', + }, + keySprintLabel: { + id: 'app.settings.game-options.setting.key-sprint.label', + defaultMessage: 'Sprint', + }, + keyInventoryLabel: { + id: 'app.settings.game-options.setting.key-inventory.label', + defaultMessage: 'Inventory', + }, + keySwapOffhandLabel: { + id: 'app.settings.game-options.setting.key-swap-offhand.label', + defaultMessage: 'Swap offhand', + }, + keyDropLabel: { + id: 'app.settings.game-options.setting.key-drop.label', + defaultMessage: 'Drop item', + }, + keyUseLabel: { + id: 'app.settings.game-options.setting.key-use.label', + defaultMessage: 'Use item', + }, + keyAttackLabel: { + id: 'app.settings.game-options.setting.key-attack.label', + defaultMessage: 'Attack', + }, + keyPickItemLabel: { + id: 'app.settings.game-options.setting.key-pick-item.label', + defaultMessage: 'Pick block', + }, + keyChatLabel: { + id: 'app.settings.game-options.setting.key-chat.label', + defaultMessage: 'Open chat', + }, + keyPlayerListLabel: { + id: 'app.settings.game-options.setting.key-player-list.label', + defaultMessage: 'Player list', + }, + keyCommandLabel: { + id: 'app.settings.game-options.setting.key-command.label', + defaultMessage: 'Command', + }, + keyScreenshotLabel: { + id: 'app.settings.game-options.setting.key-screenshot.label', + defaultMessage: 'Screenshot', + }, + keyPerspectiveLabel: { + id: 'app.settings.game-options.setting.key-perspective.label', + defaultMessage: 'Change perspective', + }, + keyFullscreenLabel: { + id: 'app.settings.game-options.setting.key-fullscreen.label', + defaultMessage: 'Toggle fullscreen', + }, + keyAdvancementsLabel: { + id: 'app.settings.game-options.setting.key-advancements.label', + defaultMessage: 'Advancements', + }, + chatVisibilityLabel: { + id: 'app.settings.game-options.setting.chat-visibility.label', + defaultMessage: 'Chat visibility', + }, + chatColorsLabel: { + id: 'app.settings.game-options.setting.chat-colors.label', + defaultMessage: 'Chat colors', + }, + chatLinksLabel: { + id: 'app.settings.game-options.setting.chat-links.label', + defaultMessage: 'Web links', + }, + chatLinksDescription: { + id: 'app.settings.game-options.setting.chat-links.description', + defaultMessage: 'Allow web links in chat to be opened.', + }, + chatLinksPromptLabel: { + id: 'app.settings.game-options.setting.chat-links-prompt.label', + defaultMessage: 'Prompt on links', + }, + chatLinksPromptDescription: { + id: 'app.settings.game-options.setting.chat-links-prompt.description', + defaultMessage: 'Ask before opening links from chat.', + }, + chatOpacityLabel: { + id: 'app.settings.game-options.setting.chat-opacity.label', + defaultMessage: 'Chat opacity', + }, + chatOpacityDescription: { + id: 'app.settings.game-options.setting.chat-opacity.description', + defaultMessage: 'The opacity of chat text.', + }, + chatScaleLabel: { + id: 'app.settings.game-options.setting.chat-scale.label', + defaultMessage: 'Chat scale', + }, + narratorLabel: { + id: 'app.settings.game-options.setting.narrator.label', + defaultMessage: 'Narrator', + }, + narratorDescription: { + id: 'app.settings.game-options.setting.narrator.description', + defaultMessage: 'Choose what the narrator reads.', + }, + subtitlesLabel: { + id: 'app.settings.game-options.setting.subtitles.label', + defaultMessage: 'Subtitles', + }, + subtitlesDescription: { + id: 'app.settings.game-options.setting.subtitles.description', + defaultMessage: 'Show captions for sounds played in the game.', + }, + highContrastLabel: { + id: 'app.settings.game-options.setting.high-contrast.label', + defaultMessage: 'High contrast', + }, + highContrastDescription: { + id: 'app.settings.game-options.setting.high-contrast.description', + defaultMessage: 'Enhance the contrast of interface elements.', + }, + darkSplashLabel: { + id: 'app.settings.game-options.setting.dark-splash.label', + defaultMessage: 'Monochrome logo', + }, + darkSplashDescription: { + id: 'app.settings.game-options.setting.dark-splash.description', + defaultMessage: 'Change the Mojang Studios loading screen from red to black.', + }, + notificationTimeLabel: { + id: 'app.settings.game-options.setting.notification-time.label', + defaultMessage: 'Notification time', + }, + notificationTimeDescription: { + id: 'app.settings.game-options.setting.notification-time.description', + defaultMessage: 'How long toast notifications remain visible.', + }, + mainHandLabel: { + id: 'app.settings.game-options.setting.main-hand.label', + defaultMessage: 'Main hand', + }, + mainHandDescription: { + id: 'app.settings.game-options.setting.main-hand.description', + defaultMessage: 'Choose whether the main hand is left or right.', + }, + capeLabel: { id: 'app.settings.game-options.setting.cape.label', defaultMessage: 'Cape' }, + capeDescription: { + id: 'app.settings.game-options.setting.cape.description', + defaultMessage: "Show the player's cape, including its elytra texture.", + }, + hatLabel: { id: 'app.settings.game-options.setting.hat.label', defaultMessage: 'Hat' }, + hatDescription: { + id: 'app.settings.game-options.setting.hat.description', + defaultMessage: 'Show the hat skin layer.', + }, + jacketLabel: { id: 'app.settings.game-options.setting.jacket.label', defaultMessage: 'Jacket' }, + jacketDescription: { + id: 'app.settings.game-options.setting.jacket.description', + defaultMessage: 'Show the jacket skin layer.', + }, + allowServerListingLabel: { + id: 'app.settings.game-options.setting.allow-server-listing.label', + defaultMessage: 'Server listings', + }, + allowServerListingDescription: { + id: 'app.settings.game-options.setting.allow-server-listing.description', + defaultMessage: "Allow the player's name to appear in server listings.", + }, + realmsNotificationsLabel: { + id: 'app.settings.game-options.setting.realms-notifications.label', + defaultMessage: 'Realms notifications', + }, +}) + +const catalogSettingMessages = defineMessages({ + brightnessLabel: { + id: 'app.settings.game-options.setting.brightness.label', + defaultMessage: 'Brightness', + }, + legacyViewDistanceLabel: { + id: 'app.settings.game-options.setting.legacy-view-distance.label', + defaultMessage: 'View distance', + }, + entityDistanceLabel: { + id: 'app.settings.game-options.setting.entity-distance.label', + defaultMessage: 'Entity distance', + }, + debugGuiScaleLabel: { + id: 'app.settings.game-options.setting.debug-gui-scale.label', + defaultMessage: 'Debug GUI scale', + }, + graphicsBackendLabel: { + id: 'app.settings.game-options.setting.graphics-backend.label', + defaultMessage: 'Graphics backend', + }, + cloudRangeLabel: { + id: 'app.settings.game-options.setting.cloud-range.label', + defaultMessage: 'Cloud distance', + }, + exclusiveFullscreenLabel: { + id: 'app.settings.game-options.setting.exclusive-fullscreen.label', + defaultMessage: 'Exclusive fullscreen', + }, + macFullscreenMenuLabel: { + id: 'app.settings.game-options.setting.mac-fullscreen-menu.label', + defaultMessage: 'Show macOS menu in fullscreen', + }, + legacyFramerateLimitLabel: { + id: 'app.settings.game-options.setting.legacy-framerate-limit.label', + defaultMessage: 'Framerate limit', + }, + inactivityFramerateLimitLabel: { + id: 'app.settings.game-options.setting.inactivity-framerate-limit.label', + defaultMessage: 'Reduced framerate', + }, + prioritizeChunkUpdatesLabel: { + id: 'app.settings.game-options.setting.prioritize-chunk-updates.label', + defaultMessage: 'Prioritize chunk updates', + }, + attackIndicatorLabel: { + id: 'app.settings.game-options.setting.attack-indicator.label', + defaultMessage: 'Attack indicator', + }, + reducedDebugInfoLabel: { + id: 'app.settings.game-options.setting.reduced-debug-info.label', + defaultMessage: 'Reduced debug information', + }, + chunkFadeTimeLabel: { + id: 'app.settings.game-options.setting.chunk-fade-time.label', + defaultMessage: 'Chunk fade time', + }, + cutoutLeavesLabel: { + id: 'app.settings.game-options.setting.cutout-leaves.label', + defaultMessage: 'Cutout leaves', + }, + improvedTransparencyLabel: { + id: 'app.settings.game-options.setting.improved-transparency.label', + defaultMessage: 'Improved transparency', + }, + textureFilteringLabel: { + id: 'app.settings.game-options.setting.texture-filtering.label', + defaultMessage: 'Texture filtering', + }, + anisotropyLabel: { + id: 'app.settings.game-options.setting.anisotropy.label', + defaultMessage: 'Anisotropy', + }, + vignetteLabel: { + id: 'app.settings.game-options.setting.vignette.label', + defaultMessage: 'Vignette', + }, + weatherRadiusLabel: { + id: 'app.settings.game-options.setting.weather-radius.label', + defaultMessage: 'Weather radius', + }, + advancedOpenGlLabel: { + id: 'app.settings.game-options.setting.advanced-opengl.label', + defaultMessage: 'Advanced OpenGL', + }, + anaglyph3dLabel: { + id: 'app.settings.game-options.setting.anaglyph-3d.label', + defaultMessage: '3D anaglyph', + }, + anisotropicFilteringLabel: { + id: 'app.settings.game-options.setting.anisotropic-filtering.label', + defaultMessage: 'Anisotropic filtering', + }, + alternateBlocksLabel: { + id: 'app.settings.game-options.setting.alternate-blocks.label', + defaultMessage: 'Alternate blocks', + }, + heldItemTooltipsLabel: { + id: 'app.settings.game-options.setting.held-item-tooltips.label', + defaultMessage: 'Held item tooltips', + }, + useVboLabel: { + id: 'app.settings.game-options.setting.use-vbo.label', + defaultMessage: 'Use VBOs', + }, + forceUnicodeFontLabel: { + id: 'app.settings.game-options.setting.force-unicode-font.label', + defaultMessage: 'Force Unicode font', + }, + japaneseGlyphVariantsLabel: { + id: 'app.settings.game-options.setting.japanese-glyph-variants.label', + defaultMessage: 'Japanese glyph variants', + }, + musicFrequencyLabel: { + id: 'app.settings.game-options.setting.music-frequency.label', + defaultMessage: 'Music frequency', + }, + directionalAudioLabel: { + id: 'app.settings.game-options.setting.directional-audio.label', + defaultMessage: 'Directional audio', + }, + invertHorizontalMouseLabel: { + id: 'app.settings.game-options.setting.invert-horizontal-mouse.label', + defaultMessage: 'Invert horizontal mouse', + }, + toggleAttackLabel: { + id: 'app.settings.game-options.setting.toggle-attack.label', + defaultMessage: 'Toggle attack', + }, + toggleUseLabel: { + id: 'app.settings.game-options.setting.toggle-use.label', + defaultMessage: 'Toggle use', + }, + mouseWheelSensitivityLabel: { + id: 'app.settings.game-options.setting.mouse-wheel-sensitivity.label', + defaultMessage: 'Mouse wheel sensitivity', + }, + rawMouseInputLabel: { + id: 'app.settings.game-options.setting.raw-mouse-input.label', + defaultMessage: 'Raw mouse input', + }, + touchscreenLabel: { + id: 'app.settings.game-options.setting.touchscreen.label', + defaultMessage: 'Touchscreen mode', + }, + allowCursorChangesLabel: { + id: 'app.settings.game-options.setting.allow-cursor-changes.label', + defaultMessage: 'Allow cursor changes', + }, + sprintWindowLabel: { + id: 'app.settings.game-options.setting.sprint-window.label', + defaultMessage: 'Sprint window', + }, + operatorItemsTabLabel: { + id: 'app.settings.game-options.setting.operator-items-tab.label', + defaultMessage: 'Operator items tab', + }, + ctrlClickRightClickLabel: { + id: 'app.settings.game-options.setting.ctrl-click-right-click.label', + defaultMessage: 'Control-click as right-click', + }, + quitShortcutsLabel: { + id: 'app.settings.game-options.setting.quit-shortcuts.label', + defaultMessage: 'Quit shortcuts', + }, + chatWidthLabel: { + id: 'app.settings.game-options.setting.chat-width.label', + defaultMessage: 'Chat width', + }, + focusedChatHeightLabel: { + id: 'app.settings.game-options.setting.focused-chat-height.label', + defaultMessage: 'Focused chat height', + }, + unfocusedChatHeightLabel: { + id: 'app.settings.game-options.setting.unfocused-chat-height.label', + defaultMessage: 'Unfocused chat height', + }, + chatLineSpacingLabel: { + id: 'app.settings.game-options.setting.chat-line-spacing.label', + defaultMessage: 'Chat line spacing', + }, + chatDelayLabel: { + id: 'app.settings.game-options.setting.chat-delay.label', + defaultMessage: 'Chat delay', + }, + textBackgroundOpacityLabel: { + id: 'app.settings.game-options.setting.text-background-opacity.label', + defaultMessage: 'Text background opacity', + }, + chatBackgroundOnlyLabel: { + id: 'app.settings.game-options.setting.chat-background-only.label', + defaultMessage: 'Chat background only', + }, + autoSuggestionsLabel: { + id: 'app.settings.game-options.setting.auto-suggestions.label', + defaultMessage: 'Command suggestions', + }, + secureChatOnlyLabel: { + id: 'app.settings.game-options.setting.secure-chat-only.label', + defaultMessage: 'Only show secure chat', + }, + saveChatDraftsLabel: { + id: 'app.settings.game-options.setting.save-chat-drafts.label', + defaultMessage: 'Save chat drafts', + }, + hideMatchedNamesLabel: { + id: 'app.settings.game-options.setting.hide-matched-names.label', + defaultMessage: 'Hide matched names', + }, + chatPreviewLabel: { + id: 'app.settings.game-options.setting.chat-preview.label', + defaultMessage: 'Chat preview', + }, + fovEffectsLabel: { + id: 'app.settings.game-options.setting.fov-effects.label', + defaultMessage: 'FOV effects', + }, + screenEffectsLabel: { + id: 'app.settings.game-options.setting.screen-effects.label', + defaultMessage: 'Screen effects', + }, + darknessPulsingLabel: { + id: 'app.settings.game-options.setting.darkness-pulsing.label', + defaultMessage: 'Darkness pulsing', + }, + damageTiltLabel: { + id: 'app.settings.game-options.setting.damage-tilt.label', + defaultMessage: 'Damage tilt', + }, + glintSpeedLabel: { + id: 'app.settings.game-options.setting.glint-speed.label', + defaultMessage: 'Glint speed', + }, + glintStrengthLabel: { + id: 'app.settings.game-options.setting.glint-strength.label', + defaultMessage: 'Glint strength', + }, + hideLightningFlashesLabel: { + id: 'app.settings.game-options.setting.hide-lightning-flashes.label', + defaultMessage: 'Hide lightning flashes', + }, + hideSplashTextsLabel: { + id: 'app.settings.game-options.setting.hide-splash-texts.label', + defaultMessage: 'Hide splash texts', + }, + highContrastOutlineLabel: { + id: 'app.settings.game-options.setting.high-contrast-outline.label', + defaultMessage: 'High contrast block outline', + }, + narratorHotkeyLabel: { + id: 'app.settings.game-options.setting.narrator-hotkey.label', + defaultMessage: 'Narrator hotkey', + }, + autosaveIndicatorLabel: { + id: 'app.settings.game-options.setting.autosave-indicator.label', + defaultMessage: 'Autosave indicator', + }, + panoramaSpeedLabel: { + id: 'app.settings.game-options.setting.panorama-speed.label', + defaultMessage: 'Panorama speed', + }, + menuBackgroundBlurLabel: { + id: 'app.settings.game-options.setting.menu-background-blur.label', + defaultMessage: 'Menu background blur', + }, + rotateWithMinecartLabel: { + id: 'app.settings.game-options.setting.rotate-with-minecart.label', + defaultMessage: 'Rotate with minecart', + }, + leftSleeveLabel: { + id: 'app.settings.game-options.setting.left-sleeve.label', + defaultMessage: 'Left sleeve', + }, + rightSleeveLabel: { + id: 'app.settings.game-options.setting.right-sleeve.label', + defaultMessage: 'Right sleeve', + }, + leftPantsLegLabel: { + id: 'app.settings.game-options.setting.left-pants-leg.label', + defaultMessage: 'Left pants leg', + }, + rightPantsLegLabel: { + id: 'app.settings.game-options.setting.right-pants-leg.label', + defaultMessage: 'Right pants leg', + }, + hideServerAddressLabel: { + id: 'app.settings.game-options.setting.hide-server-address.label', + defaultMessage: 'Hide server address', + }, + serverTexturesLabel: { + id: 'app.settings.game-options.setting.server-textures.label', + defaultMessage: 'Server textures', + }, + snooperLabel: { + id: 'app.settings.game-options.setting.snooper.label', + defaultMessage: 'Snooper', + }, + extraTelemetryLabel: { + id: 'app.settings.game-options.setting.extra-telemetry.label', + defaultMessage: 'Optional telemetry', + }, + inGameNotificationsLabel: { + id: 'app.settings.game-options.setting.in-game-notifications.label', + defaultMessage: 'In-game notifications', + }, + sharePresenceLabel: { + id: 'app.settings.game-options.setting.share-presence.label', + defaultMessage: 'Share presence', + }, +}) + +const catalogKeyMessages = defineMessages({ + smoothCameraLabel: { + id: 'app.settings.game-options.setting.key-smooth-camera.label', + defaultMessage: 'Toggle cinematic camera', + }, + spectatorOutlinesLabel: { + id: 'app.settings.game-options.setting.key-spectator-outlines.label', + defaultMessage: 'Highlight spectators', + }, + saveToolbarLabel: { + id: 'app.settings.game-options.setting.key-save-toolbar.label', + defaultMessage: 'Save toolbar', + }, + loadToolbarLabel: { + id: 'app.settings.game-options.setting.key-load-toolbar.label', + defaultMessage: 'Load toolbar', + }, + socialInteractionsLabel: { + id: 'app.settings.game-options.setting.key-social-interactions.label', + defaultMessage: 'Social interactions', + }, + quickActionsLabel: { + id: 'app.settings.game-options.setting.key-quick-actions.label', + defaultMessage: 'Quick actions', + }, + spectatorHotbarLabel: { + id: 'app.settings.game-options.setting.key-spectator-hotbar.label', + defaultMessage: 'Spectator hotbar', + }, + friendsLabel: { + id: 'app.settings.game-options.setting.key-friends.label', + defaultMessage: 'Friends', + }, + toggleGuiLabel: { + id: 'app.settings.game-options.setting.key-toggle-gui.label', + defaultMessage: 'Toggle HUD', + }, + toggleSpectatorShaderLabel: { + id: 'app.settings.game-options.setting.key-toggle-spectator-shader.label', + defaultMessage: 'Toggle spectator shader', + }, + hotbar1Label: { + id: 'app.settings.game-options.setting.key-hotbar-1.label', + defaultMessage: 'Hotbar 1', + }, + hotbar2Label: { + id: 'app.settings.game-options.setting.key-hotbar-2.label', + defaultMessage: 'Hotbar 2', + }, + hotbar3Label: { + id: 'app.settings.game-options.setting.key-hotbar-3.label', + defaultMessage: 'Hotbar 3', + }, + hotbar4Label: { + id: 'app.settings.game-options.setting.key-hotbar-4.label', + defaultMessage: 'Hotbar 4', + }, + hotbar5Label: { + id: 'app.settings.game-options.setting.key-hotbar-5.label', + defaultMessage: 'Hotbar 5', + }, + hotbar6Label: { + id: 'app.settings.game-options.setting.key-hotbar-6.label', + defaultMessage: 'Hotbar 6', + }, + hotbar7Label: { + id: 'app.settings.game-options.setting.key-hotbar-7.label', + defaultMessage: 'Hotbar 7', + }, + hotbar8Label: { + id: 'app.settings.game-options.setting.key-hotbar-8.label', + defaultMessage: 'Hotbar 8', + }, + hotbar9Label: { + id: 'app.settings.game-options.setting.key-hotbar-9.label', + defaultMessage: 'Hotbar 9', + }, + debugOverlayLabel: { + id: 'app.settings.game-options.setting.key-debug-overlay.label', + defaultMessage: 'Debug overlay', + }, + debugModifierLabel: { + id: 'app.settings.game-options.setting.key-debug-modifier.label', + defaultMessage: 'Debug modifier', + }, + debugReloadChunksLabel: { + id: 'app.settings.game-options.setting.key-debug-reload-chunks.label', + defaultMessage: 'Reload chunks', + }, + debugHitboxesLabel: { + id: 'app.settings.game-options.setting.key-debug-hitboxes.label', + defaultMessage: 'Show hitboxes', + }, + debugClearChatLabel: { + id: 'app.settings.game-options.setting.key-debug-clear-chat.label', + defaultMessage: 'Clear chat', + }, + debugCrashLabel: { + id: 'app.settings.game-options.setting.key-debug-crash.label', + defaultMessage: 'Trigger debug crash', + }, + debugChunkBordersLabel: { + id: 'app.settings.game-options.setting.key-debug-chunk-borders.label', + defaultMessage: 'Show chunk borders', + }, + debugAdvancedTooltipsLabel: { + id: 'app.settings.game-options.setting.key-debug-advanced-tooltips.label', + defaultMessage: 'Show advanced tooltips', + }, + debugCopyRecreateCommandLabel: { + id: 'app.settings.game-options.setting.key-debug-copy-recreate-command.label', + defaultMessage: 'Copy recreate command', + }, + debugSpectateLabel: { + id: 'app.settings.game-options.setting.key-debug-spectate.label', + defaultMessage: 'Spectate entity', + }, + debugSwitchGameModeLabel: { + id: 'app.settings.game-options.setting.key-debug-switch-game-mode.label', + defaultMessage: 'Switch game mode', + }, + debugOptionsLabel: { + id: 'app.settings.game-options.setting.key-debug-options.label', + defaultMessage: 'Debug options', + }, + debugFocusPauseLabel: { + id: 'app.settings.game-options.setting.key-debug-focus-pause.label', + defaultMessage: 'Pause on lost focus', + }, + debugDumpDynamicTexturesLabel: { + id: 'app.settings.game-options.setting.key-debug-dump-dynamic-textures.label', + defaultMessage: 'Dump dynamic textures', + }, + debugReloadResourcePacksLabel: { + id: 'app.settings.game-options.setting.key-debug-reload-resource-packs.label', + defaultMessage: 'Reload resource packs', + }, + debugProfilingLabel: { + id: 'app.settings.game-options.setting.key-debug-profiling.label', + defaultMessage: 'Start profiling', + }, + debugCopyLocationLabel: { + id: 'app.settings.game-options.setting.key-debug-copy-location.label', + defaultMessage: 'Copy location', + }, + debugDumpVersionLabel: { + id: 'app.settings.game-options.setting.key-debug-dump-version.label', + defaultMessage: 'Dump version', + }, + debugProfilingChartLabel: { + id: 'app.settings.game-options.setting.key-debug-profiling-chart.label', + defaultMessage: 'Profiling chart', + }, + debugFpsChartsLabel: { + id: 'app.settings.game-options.setting.key-debug-fps-charts.label', + defaultMessage: 'FPS charts', + }, + debugNetworkChartsLabel: { + id: 'app.settings.game-options.setting.key-debug-network-charts.label', + defaultMessage: 'Network charts', + }, + debugLightmapTextureLabel: { + id: 'app.settings.game-options.setting.key-debug-lightmap-texture.label', + defaultMessage: 'Lightmap texture', + }, + debugImprovedTransparencyLabel: { + id: 'app.settings.game-options.setting.key-debug-improved-transparency.label', + defaultMessage: 'Improved transparency debug view', + }, +}) + +const categoryMessages = defineMessages({ + skinCustomizationLabel: { + id: 'app.settings.game-options.category.skin-customization.label', + defaultMessage: 'Skin customization', + }, + skinCustomizationDescription: { + id: 'app.settings.game-options.category.skin-customization.description', + defaultMessage: 'Skin layers and main hand', + }, + videoLabel: { id: 'app.settings.game-options.category.video.label', defaultMessage: 'Video' }, + videoDescription: { + id: 'app.settings.game-options.category.video.description', + defaultMessage: 'Camera and display settings', + }, + languageLabel: { + id: 'app.settings.game-options.category.language.label', + defaultMessage: 'Language', + }, + languageDescription: { + id: 'app.settings.game-options.category.language.description', + defaultMessage: 'Game language', + }, + musicAndSoundLabel: { + id: 'app.settings.game-options.category.music-and-sound.label', + defaultMessage: 'Music and sound', + }, + musicAndSoundDescription: { + id: 'app.settings.game-options.category.music-and-sound.description', + defaultMessage: 'Volume and audio preferences', + }, + controlsLabel: { + id: 'app.settings.game-options.category.controls.label', + defaultMessage: 'Controls', + }, + controlsDescription: { + id: 'app.settings.game-options.category.controls.description', + defaultMessage: 'Mouse, movement, and key bindings', + }, + chatLabel: { id: 'app.settings.game-options.category.chat.label', defaultMessage: 'Chat' }, + chatDescription: { + id: 'app.settings.game-options.category.chat.description', + defaultMessage: 'Chat visibility and appearance', + }, + accessibilityLabel: { + id: 'app.settings.game-options.category.accessibility.label', + defaultMessage: 'Accessibility', + }, + accessibilityDescription: { + id: 'app.settings.game-options.category.accessibility.description', + defaultMessage: 'Accessibility preferences', + }, + onlineLabel: { id: 'app.settings.game-options.category.online.label', defaultMessage: 'Online' }, + onlineDescription: { + id: 'app.settings.game-options.category.online.description', + defaultMessage: 'Online and Realms preferences', + }, + customLabel: { + id: 'app.settings.game-options.category.custom.label', + defaultMessage: 'Custom settings', + }, + customDescription: { + id: 'app.settings.game-options.category.custom.description', + defaultMessage: 'Settings added by mods', + }, +}) + +const choiceMessages = defineMessages({ + fast: { id: 'app.settings.game-options.choice.fast', defaultMessage: 'Fast' }, + fancy: { id: 'app.settings.game-options.choice.fancy', defaultMessage: 'Fancy' }, + fabulous: { id: 'app.settings.game-options.choice.fabulous', defaultMessage: 'Fabulous' }, + custom: { id: 'app.settings.game-options.choice.custom', defaultMessage: 'Custom' }, + left: { id: 'app.settings.game-options.choice.left', defaultMessage: 'Left' }, + right: { id: 'app.settings.game-options.choice.right', defaultMessage: 'Right' }, + shown: { id: 'app.settings.game-options.choice.shown', defaultMessage: 'Shown' }, + commandsOnly: { + id: 'app.settings.game-options.choice.commands-only', + defaultMessage: 'Commands only', + }, + hidden: { id: 'app.settings.game-options.choice.hidden', defaultMessage: 'Hidden' }, + all: { id: 'app.settings.game-options.choice.all', defaultMessage: 'All' }, + decreased: { id: 'app.settings.game-options.choice.decreased', defaultMessage: 'Decreased' }, + minimal: { id: 'app.settings.game-options.choice.minimal', defaultMessage: 'Minimal' }, + off: { id: 'app.settings.game-options.choice.off', defaultMessage: 'Off' }, + chat: { id: 'app.settings.game-options.choice.chat', defaultMessage: 'Chat' }, + system: { id: 'app.settings.game-options.choice.system', defaultMessage: 'System' }, + on: { id: 'app.settings.game-options.choice.on', defaultMessage: 'On' }, + minimum: { id: 'app.settings.game-options.choice.minimum', defaultMessage: 'Minimum' }, + maximum: { id: 'app.settings.game-options.choice.maximum', defaultMessage: 'Maximum' }, + never: { id: 'app.settings.game-options.choice.never', defaultMessage: 'Never' }, + pause: { id: 'app.settings.game-options.choice.pause', defaultMessage: 'Pause menu' }, + pauseAndToast: { + id: 'app.settings.game-options.choice.pause-and-toast', + defaultMessage: 'Pause menu and toast', + }, + far: { id: 'app.settings.game-options.choice.far', defaultMessage: 'Far' }, + normal: { id: 'app.settings.game-options.choice.normal', defaultMessage: 'Normal' }, + short: { id: 'app.settings.game-options.choice.short', defaultMessage: 'Short' }, + tiny: { id: 'app.settings.game-options.choice.tiny', defaultMessage: 'Tiny' }, + maxFps: { id: 'app.settings.game-options.choice.max-fps', defaultMessage: 'Max FPS' }, + balanced: { id: 'app.settings.game-options.choice.balanced', defaultMessage: 'Balanced' }, + powerSaver: { id: 'app.settings.game-options.choice.power-saver', defaultMessage: 'Power saver' }, + whileAfk: { id: 'app.settings.game-options.choice.while-afk', defaultMessage: 'While AFK' }, + whenMinimized: { + id: 'app.settings.game-options.choice.when-minimized', + defaultMessage: 'When minimized', + }, + none: { id: 'app.settings.game-options.choice.none', defaultMessage: 'None' }, + byPlayer: { id: 'app.settings.game-options.choice.by-player', defaultMessage: 'By player' }, + nearby: { id: 'app.settings.game-options.choice.nearby', defaultMessage: 'Nearby' }, + crosshair: { id: 'app.settings.game-options.choice.crosshair', defaultMessage: 'Crosshair' }, + hotbar: { id: 'app.settings.game-options.choice.hotbar', defaultMessage: 'Hotbar' }, + constant: { id: 'app.settings.game-options.choice.constant', defaultMessage: 'Constant' }, + default: { id: 'app.settings.game-options.choice.default', defaultMessage: 'Default' }, + frequent: { id: 'app.settings.game-options.choice.frequent', defaultMessage: 'Frequent' }, + limited: { id: 'app.settings.game-options.choice.limited', defaultMessage: 'Limited' }, + openGl: { id: 'app.settings.game-options.choice.opengl', defaultMessage: 'OpenGL' }, + vulkan: { id: 'app.settings.game-options.choice.vulkan', defaultMessage: 'Vulkan' }, +}) + +export const presentationMessages = defineMessages({ + customValuePlaceholder: { + id: 'app.settings.game-options.custom-value.placeholder', + defaultMessage: 'Enter a value', + }, + validationMissingValue: { + id: 'app.settings.game-options.validation.missing-value', + defaultMessage: 'Choose a value first.', + }, + validationNoCompatibleInstances: { + id: 'app.settings.game-options.validation.no-compatible-instances', + defaultMessage: 'Some of your instances cannot use this setting', + }, + validationInvalidValue: { + id: 'app.settings.game-options.validation.invalid-value', + defaultMessage: 'Choose a valid value.', + }, + validationChangedSinceOpened: { + id: 'app.settings.game-options.validation.changed-since-opened', + defaultMessage: 'This setting changed elsewhere. Check it and try again.', + }, + compatibilityNone: { + id: 'app.settings.game-options.compatibility.none', + defaultMessage: 'Some of your instances cannot use this setting', + }, + bucketLauncherControlled: { + id: 'app.settings.game-options.compatibility.reason.launcher-controlled', + defaultMessage: 'This setting is managed by Modrinth’s launch settings.', + }, +}) + +const knownSettings: Record = + { + fov: { label: settingMessages.fovLabel }, + graphics: { + label: settingMessages.graphicsLabel, + description: settingMessages.graphicsDescription, + }, + ambient_occlusion: { label: settingMessages.ambientOcclusionLabel }, + render_distance: { label: settingMessages.renderDistanceLabel }, + simulation_distance: { + label: settingMessages.simulationDistanceLabel, + description: settingMessages.simulationDistanceDescription, + }, + gui_scale: { + label: settingMessages.guiScaleLabel, + description: settingMessages.guiScaleDescription, + }, + particles: { label: settingMessages.particlesLabel }, + clouds: { label: settingMessages.cloudsLabel }, + entity_shadows: { label: settingMessages.entityShadowsLabel }, + view_bobbing: { + label: settingMessages.viewBobbingLabel, + description: settingMessages.viewBobbingDescription, + }, + vsync: { label: settingMessages.vsyncLabel, description: settingMessages.vsyncDescription }, + fullscreen: { label: settingMessages.fullscreenLabel }, + max_framerate: { label: settingMessages.maxFramerateLabel }, + mipmap_levels: { + label: settingMessages.mipmapLevelsLabel, + description: settingMessages.mipmapLevelsDescription, + }, + biome_blend_radius: { + label: settingMessages.biomeBlendRadiusLabel, + description: settingMessages.biomeBlendRadiusDescription, + }, + language: { label: settingMessages.languageLabel }, + master_volume: { label: settingMessages.masterVolumeLabel }, + music_volume: { label: settingMessages.musicVolumeLabel }, + music_toast: { + label: settingMessages.musicToastLabel, + description: settingMessages.musicToastDescription, + }, + record_volume: { label: settingMessages.recordVolumeLabel }, + weather_volume: { label: settingMessages.weatherVolumeLabel }, + blocks_volume: { label: settingMessages.blocksVolumeLabel }, + hostile_volume: { label: settingMessages.hostileVolumeLabel }, + neutral_volume: { label: settingMessages.neutralVolumeLabel }, + players_volume: { label: settingMessages.playersVolumeLabel }, + ambient_volume: { label: settingMessages.ambientVolumeLabel }, + voice_volume: { label: settingMessages.voiceVolumeLabel }, + ui_volume: { label: settingMessages.uiVolumeLabel }, + sensitivity: { label: settingMessages.sensitivityLabel }, + invert_mouse: { + label: settingMessages.invertMouseLabel, + description: settingMessages.invertMouseDescription, + }, + auto_jump: { + label: settingMessages.autoJumpLabel, + description: settingMessages.autoJumpDescription, + }, + toggle_crouch: { + label: settingMessages.toggleCrouchLabel, + description: settingMessages.toggleCrouchDescription, + }, + toggle_sprint: { + label: settingMessages.toggleSprintLabel, + description: settingMessages.toggleSprintDescription, + }, + discrete_mouse_scroll: { + label: settingMessages.discreteMouseScrollLabel, + description: settingMessages.discreteMouseScrollDescription, + }, + 'key.forward': { label: settingMessages.keyForwardLabel }, + 'key.left': { label: settingMessages.keyLeftLabel }, + 'key.back': { label: settingMessages.keyBackLabel }, + 'key.right': { label: settingMessages.keyRightLabel }, + 'key.jump': { label: settingMessages.keyJumpLabel }, + 'key.sneak': { label: settingMessages.keySneakLabel }, + 'key.sprint': { label: settingMessages.keySprintLabel }, + 'key.inventory': { label: settingMessages.keyInventoryLabel }, + 'key.swap_offhand': { label: settingMessages.keySwapOffhandLabel }, + 'key.drop': { label: settingMessages.keyDropLabel }, + 'key.use': { label: settingMessages.keyUseLabel }, + 'key.attack': { label: settingMessages.keyAttackLabel }, + 'key.pick_item': { label: settingMessages.keyPickItemLabel }, + 'key.chat': { label: settingMessages.keyChatLabel }, + 'key.player_list': { label: settingMessages.keyPlayerListLabel }, + 'key.command': { label: settingMessages.keyCommandLabel }, + 'key.screenshot': { label: settingMessages.keyScreenshotLabel }, + 'key.perspective': { label: settingMessages.keyPerspectiveLabel }, + 'key.fullscreen': { label: settingMessages.keyFullscreenLabel }, + 'key.advancements': { label: settingMessages.keyAdvancementsLabel }, + chat_visibility: { label: settingMessages.chatVisibilityLabel }, + chat_colors: { label: settingMessages.chatColorsLabel }, + chat_links: { + label: settingMessages.chatLinksLabel, + description: settingMessages.chatLinksDescription, + }, + chat_links_prompt: { + label: settingMessages.chatLinksPromptLabel, + description: settingMessages.chatLinksPromptDescription, + }, + chat_opacity: { + label: settingMessages.chatOpacityLabel, + description: settingMessages.chatOpacityDescription, + }, + chat_scale: { label: settingMessages.chatScaleLabel }, + narrator: { + label: settingMessages.narratorLabel, + description: settingMessages.narratorDescription, + }, + subtitles: { + label: settingMessages.subtitlesLabel, + description: settingMessages.subtitlesDescription, + }, + high_contrast: { + label: settingMessages.highContrastLabel, + description: settingMessages.highContrastDescription, + }, + dark_splash: { + label: settingMessages.darkSplashLabel, + description: settingMessages.darkSplashDescription, + }, + notification_time: { + label: settingMessages.notificationTimeLabel, + description: settingMessages.notificationTimeDescription, + }, + main_hand: { + label: settingMessages.mainHandLabel, + description: settingMessages.mainHandDescription, + }, + cape: { label: settingMessages.capeLabel, description: settingMessages.capeDescription }, + hat: { label: settingMessages.hatLabel, description: settingMessages.hatDescription }, + jacket: { label: settingMessages.jacketLabel, description: settingMessages.jacketDescription }, + allow_server_listing: { + label: settingMessages.allowServerListingLabel, + description: settingMessages.allowServerListingDescription, + }, + realms_notifications: { label: settingMessages.realmsNotificationsLabel }, + brightness: { label: catalogSettingMessages.brightnessLabel }, + legacy_view_distance: { label: catalogSettingMessages.legacyViewDistanceLabel }, + entity_distance: { label: catalogSettingMessages.entityDistanceLabel }, + debug_gui_scale: { label: catalogSettingMessages.debugGuiScaleLabel }, + graphics_backend: { label: catalogSettingMessages.graphicsBackendLabel }, + cloud_range: { label: catalogSettingMessages.cloudRangeLabel }, + exclusive_fullscreen: { label: catalogSettingMessages.exclusiveFullscreenLabel }, + mac_fullscreen_menu: { label: catalogSettingMessages.macFullscreenMenuLabel }, + legacy_framerate_limit: { label: catalogSettingMessages.legacyFramerateLimitLabel }, + inactivity_framerate_limit: { + label: catalogSettingMessages.inactivityFramerateLimitLabel, + }, + prioritize_chunk_updates: { label: catalogSettingMessages.prioritizeChunkUpdatesLabel }, + attack_indicator: { label: catalogSettingMessages.attackIndicatorLabel }, + reduced_debug_info: { label: catalogSettingMessages.reducedDebugInfoLabel }, + chunk_fade_time: { label: catalogSettingMessages.chunkFadeTimeLabel }, + cutout_leaves: { label: catalogSettingMessages.cutoutLeavesLabel }, + improved_transparency: { label: catalogSettingMessages.improvedTransparencyLabel }, + texture_filtering: { label: catalogSettingMessages.textureFilteringLabel }, + anisotropy: { label: catalogSettingMessages.anisotropyLabel }, + vignette: { label: catalogSettingMessages.vignetteLabel }, + weather_radius: { label: catalogSettingMessages.weatherRadiusLabel }, + advanced_opengl: { label: catalogSettingMessages.advancedOpenGlLabel }, + anaglyph_3d: { label: catalogSettingMessages.anaglyph3dLabel }, + anisotropic_filtering: { label: catalogSettingMessages.anisotropicFilteringLabel }, + alternate_blocks: { label: catalogSettingMessages.alternateBlocksLabel }, + held_item_tooltips: { label: catalogSettingMessages.heldItemTooltipsLabel }, + use_vbo: { label: catalogSettingMessages.useVboLabel }, + force_unicode_font: { label: catalogSettingMessages.forceUnicodeFontLabel }, + japanese_glyph_variants: { label: catalogSettingMessages.japaneseGlyphVariantsLabel }, + music_frequency: { label: catalogSettingMessages.musicFrequencyLabel }, + directional_audio: { label: catalogSettingMessages.directionalAudioLabel }, + invert_horizontal_mouse: { label: catalogSettingMessages.invertHorizontalMouseLabel }, + toggle_attack: { label: catalogSettingMessages.toggleAttackLabel }, + toggle_use: { label: catalogSettingMessages.toggleUseLabel }, + mouse_wheel_sensitivity: { label: catalogSettingMessages.mouseWheelSensitivityLabel }, + raw_mouse_input: { label: catalogSettingMessages.rawMouseInputLabel }, + touchscreen: { label: catalogSettingMessages.touchscreenLabel }, + allow_cursor_changes: { label: catalogSettingMessages.allowCursorChangesLabel }, + sprint_window: { label: catalogSettingMessages.sprintWindowLabel }, + operator_items_tab: { label: catalogSettingMessages.operatorItemsTabLabel }, + ctrl_click_right_click: { label: catalogSettingMessages.ctrlClickRightClickLabel }, + quit_shortcuts: { label: catalogSettingMessages.quitShortcutsLabel }, + chat_width: { label: catalogSettingMessages.chatWidthLabel }, + focused_chat_height: { label: catalogSettingMessages.focusedChatHeightLabel }, + unfocused_chat_height: { label: catalogSettingMessages.unfocusedChatHeightLabel }, + chat_line_spacing: { label: catalogSettingMessages.chatLineSpacingLabel }, + chat_delay: { label: catalogSettingMessages.chatDelayLabel }, + text_background_opacity: { label: catalogSettingMessages.textBackgroundOpacityLabel }, + chat_background_only: { label: catalogSettingMessages.chatBackgroundOnlyLabel }, + auto_suggestions: { label: catalogSettingMessages.autoSuggestionsLabel }, + secure_chat_only: { label: catalogSettingMessages.secureChatOnlyLabel }, + save_chat_drafts: { label: catalogSettingMessages.saveChatDraftsLabel }, + hide_matched_names: { label: catalogSettingMessages.hideMatchedNamesLabel }, + chat_preview: { label: catalogSettingMessages.chatPreviewLabel }, + fov_effects: { label: catalogSettingMessages.fovEffectsLabel }, + screen_effects: { label: catalogSettingMessages.screenEffectsLabel }, + darkness_pulsing: { label: catalogSettingMessages.darknessPulsingLabel }, + damage_tilt: { label: catalogSettingMessages.damageTiltLabel }, + glint_speed: { label: catalogSettingMessages.glintSpeedLabel }, + glint_strength: { label: catalogSettingMessages.glintStrengthLabel }, + hide_lightning_flashes: { label: catalogSettingMessages.hideLightningFlashesLabel }, + hide_splash_texts: { label: catalogSettingMessages.hideSplashTextsLabel }, + high_contrast_outline: { label: catalogSettingMessages.highContrastOutlineLabel }, + narrator_hotkey: { label: catalogSettingMessages.narratorHotkeyLabel }, + autosave_indicator: { label: catalogSettingMessages.autosaveIndicatorLabel }, + panorama_speed: { label: catalogSettingMessages.panoramaSpeedLabel }, + menu_background_blur: { label: catalogSettingMessages.menuBackgroundBlurLabel }, + rotate_with_minecart: { label: catalogSettingMessages.rotateWithMinecartLabel }, + left_sleeve: { label: catalogSettingMessages.leftSleeveLabel }, + right_sleeve: { label: catalogSettingMessages.rightSleeveLabel }, + left_pants_leg: { label: catalogSettingMessages.leftPantsLegLabel }, + right_pants_leg: { label: catalogSettingMessages.rightPantsLegLabel }, + hide_server_address: { label: catalogSettingMessages.hideServerAddressLabel }, + server_textures: { label: catalogSettingMessages.serverTexturesLabel }, + snooper: { label: catalogSettingMessages.snooperLabel }, + extra_telemetry: { label: catalogSettingMessages.extraTelemetryLabel }, + in_game_notifications: { label: catalogSettingMessages.inGameNotificationsLabel }, + share_presence: { label: catalogSettingMessages.sharePresenceLabel }, + 'key.smooth_camera': { label: catalogKeyMessages.smoothCameraLabel }, + 'key.spectator_outlines': { label: catalogKeyMessages.spectatorOutlinesLabel }, + 'key.save_toolbar': { label: catalogKeyMessages.saveToolbarLabel }, + 'key.load_toolbar': { label: catalogKeyMessages.loadToolbarLabel }, + 'key.social_interactions': { label: catalogKeyMessages.socialInteractionsLabel }, + 'key.quick_actions': { label: catalogKeyMessages.quickActionsLabel }, + 'key.spectator_hotbar': { label: catalogKeyMessages.spectatorHotbarLabel }, + 'key.friends': { label: catalogKeyMessages.friendsLabel }, + 'key.toggle_gui': { label: catalogKeyMessages.toggleGuiLabel }, + 'key.toggle_spectator_shader': { label: catalogKeyMessages.toggleSpectatorShaderLabel }, + 'key.hotbar.1': { label: catalogKeyMessages.hotbar1Label }, + 'key.hotbar.2': { label: catalogKeyMessages.hotbar2Label }, + 'key.hotbar.3': { label: catalogKeyMessages.hotbar3Label }, + 'key.hotbar.4': { label: catalogKeyMessages.hotbar4Label }, + 'key.hotbar.5': { label: catalogKeyMessages.hotbar5Label }, + 'key.hotbar.6': { label: catalogKeyMessages.hotbar6Label }, + 'key.hotbar.7': { label: catalogKeyMessages.hotbar7Label }, + 'key.hotbar.8': { label: catalogKeyMessages.hotbar8Label }, + 'key.hotbar.9': { label: catalogKeyMessages.hotbar9Label }, + 'key.debug.overlay': { label: catalogKeyMessages.debugOverlayLabel }, + 'key.debug.modifier': { label: catalogKeyMessages.debugModifierLabel }, + 'key.debug.reload_chunks': { label: catalogKeyMessages.debugReloadChunksLabel }, + 'key.debug.hitboxes': { label: catalogKeyMessages.debugHitboxesLabel }, + 'key.debug.clear_chat': { label: catalogKeyMessages.debugClearChatLabel }, + 'key.debug.crash': { label: catalogKeyMessages.debugCrashLabel }, + 'key.debug.chunk_borders': { label: catalogKeyMessages.debugChunkBordersLabel }, + 'key.debug.advanced_tooltips': { + label: catalogKeyMessages.debugAdvancedTooltipsLabel, + }, + 'key.debug.copy_recreate_command': { + label: catalogKeyMessages.debugCopyRecreateCommandLabel, + }, + 'key.debug.spectate': { label: catalogKeyMessages.debugSpectateLabel }, + 'key.debug.switch_game_mode': { label: catalogKeyMessages.debugSwitchGameModeLabel }, + 'key.debug.options': { label: catalogKeyMessages.debugOptionsLabel }, + 'key.debug.focus_pause': { label: catalogKeyMessages.debugFocusPauseLabel }, + 'key.debug.dump_dynamic_textures': { + label: catalogKeyMessages.debugDumpDynamicTexturesLabel, + }, + 'key.debug.reload_resource_packs': { + label: catalogKeyMessages.debugReloadResourcePacksLabel, + }, + 'key.debug.profiling': { label: catalogKeyMessages.debugProfilingLabel }, + 'key.debug.copy_location': { label: catalogKeyMessages.debugCopyLocationLabel }, + 'key.debug.dump_version': { label: catalogKeyMessages.debugDumpVersionLabel }, + 'key.debug.profiling_chart': { label: catalogKeyMessages.debugProfilingChartLabel }, + 'key.debug.fps_charts': { label: catalogKeyMessages.debugFpsChartsLabel }, + 'key.debug.network_charts': { label: catalogKeyMessages.debugNetworkChartsLabel }, + 'key.debug.lightmap_texture': { label: catalogKeyMessages.debugLightmapTextureLabel }, + 'key.debug.improved_transparency': { + label: catalogKeyMessages.debugImprovedTransparencyLabel, + }, + } + +const categories: Record = { + skin_customization: { + label: categoryMessages.skinCustomizationLabel, + description: categoryMessages.skinCustomizationDescription, + }, + video: { label: categoryMessages.videoLabel, description: categoryMessages.videoDescription }, + video_settings: { + label: categoryMessages.videoLabel, + description: categoryMessages.videoDescription, + }, + language: { + label: categoryMessages.languageLabel, + description: categoryMessages.languageDescription, + }, + music_and_sound: { + label: categoryMessages.musicAndSoundLabel, + description: categoryMessages.musicAndSoundDescription, + }, + controls: { + label: categoryMessages.controlsLabel, + description: categoryMessages.controlsDescription, + }, + chat: { label: categoryMessages.chatLabel, description: categoryMessages.chatDescription }, + chat_settings: { + label: categoryMessages.chatLabel, + description: categoryMessages.chatDescription, + }, + accessibility: { + label: categoryMessages.accessibilityLabel, + description: categoryMessages.accessibilityDescription, + }, + online: { label: categoryMessages.onlineLabel, description: categoryMessages.onlineDescription }, + custom: { label: categoryMessages.customLabel, description: categoryMessages.customDescription }, + custom_settings: { + label: categoryMessages.customLabel, + description: categoryMessages.customDescription, + }, +} + +const choices: Record = { + 'graphics:fast': choiceMessages.fast, + 'graphics:fancy': choiceMessages.fancy, + 'graphics:fabulous': choiceMessages.fabulous, + 'graphics:custom': choiceMessages.custom, + 'main_hand:left': choiceMessages.left, + 'main_hand:right': choiceMessages.right, + 'chat_visibility:0': choiceMessages.shown, + 'chat_visibility:1': choiceMessages.commandsOnly, + 'chat_visibility:2': choiceMessages.hidden, + 'particles:0': choiceMessages.all, + 'particles:1': choiceMessages.decreased, + 'particles:2': choiceMessages.minimal, + 'narrator:0': choiceMessages.off, + 'narrator:1': choiceMessages.all, + 'narrator:2': choiceMessages.chat, + 'narrator:3': choiceMessages.system, + 'clouds:false': choiceMessages.off, + 'clouds:fast': choiceMessages.fast, + 'clouds:true': choiceMessages.fancy, + 'ambient_occlusion:off': choiceMessages.off, + 'ambient_occlusion:on': choiceMessages.on, + 'ambient_occlusion:minimum': choiceMessages.minimum, + 'ambient_occlusion:maximum': choiceMessages.maximum, + 'music_toast:never': choiceMessages.never, + 'music_toast:pause': choiceMessages.pause, + 'music_toast:pause_and_toast': choiceMessages.pauseAndToast, + 'legacy_view_distance:0': choiceMessages.far, + 'legacy_view_distance:1': choiceMessages.normal, + 'legacy_view_distance:2': choiceMessages.short, + 'legacy_view_distance:3': choiceMessages.tiny, + 'legacy_framerate_limit:0': choiceMessages.maxFps, + 'legacy_framerate_limit:1': choiceMessages.balanced, + 'legacy_framerate_limit:2': choiceMessages.powerSaver, + 'inactivity_framerate_limit:afk': choiceMessages.whileAfk, + 'inactivity_framerate_limit:minimized': choiceMessages.whenMinimized, + 'prioritize_chunk_updates:0': choiceMessages.none, + 'prioritize_chunk_updates:1': choiceMessages.byPlayer, + 'prioritize_chunk_updates:2': choiceMessages.nearby, + 'attack_indicator:0': choiceMessages.off, + 'attack_indicator:1': choiceMessages.crosshair, + 'attack_indicator:2': choiceMessages.hotbar, + 'chat_preview:0': choiceMessages.off, + 'chat_preview:1': choiceMessages.commandsOnly, + 'chat_preview:2': choiceMessages.on, + 'music_frequency:CONSTANT': choiceMessages.constant, + 'music_frequency:DEFAULT': choiceMessages.default, + 'music_frequency:FREQUENT': choiceMessages.frequent, + 'share_presence:all': choiceMessages.all, + 'share_presence:limited': choiceMessages.limited, + 'share_presence:none': choiceMessages.none, + 'graphics_backend:default': choiceMessages.default, + 'graphics_backend:opengl': choiceMessages.openGl, + 'graphics_backend:vulkan': choiceMessages.vulkan, +} + +const validationMessages: Record = { + missing_value: presentationMessages.validationMissingValue, + no_compatible_instances: presentationMessages.validationNoCompatibleInstances, + invalid_value: presentationMessages.validationInvalidValue, + changed_since_opened: presentationMessages.validationChangedSinceOpened, +} + +export function formatGameSettingLabel( + formatMessage: FormatMessage, + setting: EditableGameSetting, +): string { + if (setting.kind === 'external') return setting.raw_key ?? setting.option_id + const definition = knownSettings[setting.option_id] + return definition ? formatMessage(definition.label) : setting.option_id +} + +export function formatGameSettingDescription( + formatMessage: FormatMessage, + setting: EditableGameSetting, +): string { + if (setting.kind === 'external') return '' + const definition = knownSettings[setting.option_id] + return definition?.description ? formatMessage(definition.description) : '' +} + +export function gameSettingCategoryMessage(category: GameSettingCategory): MessageDescriptor { + return ( + categories[category.id]?.label ?? { + id: `app.settings.game-options.category.${category.id}.label`, + defaultMessage: category.id, + } + ) +} + +export function formatGameSettingChoice( + formatMessage: FormatMessage, + optionId: string, + value: string, +): string { + const message = choices[`${optionId}:${value}`] + return message ? formatMessage(message) : value +} + +export function formatGameSettingValidation( + formatMessage: FormatMessage, + error: GameOptionValidationError | null | undefined, +): string | null { + return error ? formatMessage(validationMessages[error]) : null +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/row.vue b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/row.vue new file mode 100644 index 0000000..6586b17 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/row.vue @@ -0,0 +1,384 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/use-editor.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/use-editor.ts new file mode 100644 index 0000000..9446de5 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/use-editor.ts @@ -0,0 +1,448 @@ +import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, type MaybeRefOrGetter, nextTick, onScopeDispose, ref, toValue, watch } from 'vue' + +import { + type EditableGameSetting, + type GameOptionCanonicalValue, + type GameSettingChange, + type GameSettingsEditorState, + gameSettingsKeys, + gameSettingsQueryOptions, + get_local_game_options_config, + get_synced_game_options_config, + preview_local_game_option_changes, + preview_synced_game_option_changes, + save_local_game_option_changes, + save_synced_game_option_changes, + type UpdateGameSettingsRequest, +} from '@/helpers/game-options' + +import { canonicalValuesEqual, cloneGameSettingsState, gameSettingChanges } from './editors' + +export function useGameSettingsEditor( + instanceId: MaybeRefOrGetter, + onSaved: () => void, +) { + const { addNotification, handleError } = injectNotificationManager() + const { formatMessage } = useVIntl() + const queryClient = useQueryClient() + const messages = defineMessages({ + conflictTitle: { + id: 'app.settings.synced-options.game-settings.conflict-title', + defaultMessage: 'These settings changed elsewhere', + }, + conflictText: { + id: 'app.settings.synced-options.game-settings.conflict-text', + defaultMessage: 'We loaded the latest settings. Check your changes, then save again.', + }, + }) + + const baseState = ref(null) + const draftState = ref(null) + const touchedValueOptionIds = ref>(new Set()) + let previewTimer: ReturnType | null = null + let previewGeneration = 0 + let loadGeneration = 0 + let saveGeneration = 0 + + const editorInstanceId = ref() + const isLocalEditor = computed(() => !!editorInstanceId.value) + + const dirtyChanges = computed(() => + gameSettingChanges(baseState.value, draftState.value, touchedValueOptionIds.value), + ) + const isDirty = computed(() => dirtyChanges.value.length > 0) + const dirtyOptionIds = computed( + () => new Set(dirtyChanges.value.map((change) => change.option_id)), + ) + const hasBlockingDraft = computed( + () => + draftState.value?.settings.some( + (setting) => + dirtyOptionIds.value.has(setting.option_id) && + (isLocalEditor.value || setting.sync_enabled) && + (!!setting.validation_error || + ['mixed', 'unset', 'invalid'].includes(setting.value_state)), + ) ?? false, + ) + const stateQuery = useQuery( + computed(() => ({ + ...gameSettingsQueryOptions(editorInstanceId.value), + enabled: false, + })), + ) + const loading = computed( + () => !draftState.value && (stateQuery.isPending.value || stateQuery.isFetching.value), + ) + const loadError = computed(() => !draftState.value && stateQuery.isError.value) + const previewMutation = useMutation({ + mutationFn: ({ + instanceId, + request, + }: { + instanceId?: string + request: UpdateGameSettingsRequest + }) => + instanceId + ? preview_local_game_option_changes(instanceId, request) + : preview_synced_game_option_changes(request), + }) + const saveMutation = useMutation({ + mutationFn: saveDraft, + onError: handleError, + }) + + function applyState(state: GameSettingsEditorState) { + baseState.value = cloneGameSettingsState(state) + draftState.value = cloneGameSettingsState(state) + touchedValueOptionIds.value = new Set() + } + + watch(stateQuery.data, (state) => { + if (state && draftState.value && !isDirty.value && !saveMutation.isPending.value) { + applyState(state) + } + }) + + async function load() { + const generation = ++loadGeneration + cancelPreview() + editorInstanceId.value = toValue(instanceId) + const options = gameSettingsQueryOptions(editorInstanceId.value) + const cached = !editorInstanceId.value && queryClient.getQueryData(options.queryKey) + if (cached) { + applyState(cached) + try { + // A previously cached empty state is common immediately after enabling + // sync. Refresh it before returning so the editor does not stay empty. + const refreshed = await queryClient.fetchQuery({ ...options, staleTime: 0 }) + if (generation !== loadGeneration) return false + applyState(refreshed) + return true + } catch (error) { + if (generation !== loadGeneration) return false + handleError(error) + return true + } + } + await nextTick() + const result = await queryClient + .fetchQuery({ ...options, staleTime: 0 }) + .then((data) => ({ data, isError: false, error: null as unknown })) + .catch((error) => ({ data: undefined, isError: true, error })) + if (generation !== loadGeneration) return false + if (result.isError) { + handleError(result.error) + return false + } + if (!result.data) return false + applyState(result.data) + return true + } + + function cancelPreview() { + if (previewTimer) clearTimeout(previewTimer) + previewTimer = null + previewGeneration++ + } + + function reset() { + cancelPreview() + loadGeneration++ + saveGeneration++ + baseState.value = null + draftState.value = null + touchedValueOptionIds.value = new Set() + saveMutation.reset() + } + + function cancelChanges() { + if (!baseState.value) return + cancelPreview() + draftState.value = cloneGameSettingsState(baseState.value) + touchedValueOptionIds.value = new Set() + } + + function setSyncEnabled(optionIds: readonly string[], enabled: boolean) { + if (!draftState.value) return + const ids = new Set(optionIds) + draftState.value.settings = draftState.value.settings.map((setting) => + ids.has(setting.option_id) + ? { ...setting, sync_enabled: enabled, validation_error: null } + : setting, + ) + schedulePreview() + } + + function schedulePreview() { + cancelPreview() + previewTimer = setTimeout(() => void preview(), 350) + } + + function save() { + if (saveMutation.isPending.value || loading.value || hasBlockingDraft.value || !isDirty.value) + return + saveMutation.mutate() + } + + async function saveAndWait(): Promise { + if (loading.value || hasBlockingDraft.value || !isDirty.value) return !hasBlockingDraft.value + try { + await saveMutation.mutateAsync() + return true + } catch { + return false + } + } + + function editorRequest(): UpdateGameSettingsRequest | null { + if (!baseState.value || !draftState.value) return null + return { + expected_summary_revision: draftState.value.summary_revision, + expected_canonical_revision: draftState.value.canonical_revision, + expected_catalog_revision: draftState.value.catalog_revision, + changes: dirtyChanges.value, + } + } + + function setCanonicalValue(optionId: string, value: GameOptionCanonicalValue | null) { + if (!draftState.value) return + const currentSetting = draftState.value.settings.find( + (setting) => setting.option_id === optionId, + ) + const baseSetting = baseState.value?.settings.find((setting) => setting.option_id === optionId) + if (!currentSetting) return + + const revertedToBase = + !!baseSetting && + !canonicalValuesEqual(currentSetting.canonical_value, baseSetting.canonical_value) && + canonicalValuesEqual(value, baseSetting.canonical_value) + const touchedOptionIds = new Set(touchedValueOptionIds.value) + if (revertedToBase) { + touchedOptionIds.delete(optionId) + } else { + touchedOptionIds.add(optionId) + } + touchedValueOptionIds.value = touchedOptionIds + draftState.value.settings = draftState.value.settings.map((setting) => + setting.option_id === optionId + ? { + ...setting, + canonical_value: value, + value_state: revertedToBase ? baseSetting.value_state : value ? 'canonical' : 'unset', + validation_error: revertedToBase ? baseSetting.validation_error : null, + } + : setting, + ) + schedulePreview() + } + + function mergePreview(preview: GameSettingsEditorState) { + if (!baseState.value || !draftState.value) return + const previousBase = cloneGameSettingsState(baseState.value) + const previousDraft = cloneGameSettingsState(draftState.value) + const dirtyIds = new Set( + gameSettingChanges(previousBase, previousDraft, touchedValueOptionIds.value).map( + (change) => change.option_id, + ), + ) + const stagedSettings = new Map( + previousDraft.settings + .filter((setting) => dirtyIds.has(setting.option_id)) + .map((setting) => [setting.option_id, setting]), + ) + const previousBaseSettings = new Map( + previousBase.settings.map((setting) => [setting.option_id, setting]), + ) + const nextBase = cloneGameSettingsState(preview) + const nextDraft = cloneGameSettingsState(preview) + nextBase.settings = nextBase.settings.map((setting) => + dirtyIds.has(setting.option_id) + ? { ...(previousBaseSettings.get(setting.option_id) ?? setting) } + : setting, + ) + nextDraft.settings = nextDraft.settings.map((setting): EditableGameSetting => { + const staged = stagedSettings.get(setting.option_id) + if (!staged) return setting + return { + ...setting, + sync_enabled: staged.sync_enabled, + canonical_value: staged.canonical_value, + option_revision: staged.option_revision, + } + }) + baseState.value = nextBase + draftState.value = nextDraft + } + + async function preview() { + const request = editorRequest() + if (!request || request.changes.length === 0) { + previewGeneration++ + if (baseState.value) draftState.value = cloneGameSettingsState(baseState.value) + return + } + + const generation = ++previewGeneration + try { + const previewState = await previewMutation.mutateAsync({ + instanceId: editorInstanceId.value, + request, + }) + if (generation !== previewGeneration) return + mergePreview(previewState) + } catch { + return + } + } + + function applyChangesToRefreshedState( + refreshed: GameSettingsEditorState, + stagedDraft: GameSettingsEditorState, + changes: GameSettingChange[], + touchedOptionIds: ReadonlySet, + conflictOptionIds: ReadonlySet = new Set(), + ) { + const changesById = new Map(changes.map((change) => [change.option_id, change])) + const stagedSettings = new Map( + stagedDraft.settings.map((setting) => [setting.option_id, setting]), + ) + const nextBase = cloneGameSettingsState(refreshed) + const nextDraft = cloneGameSettingsState(refreshed) + + nextDraft.settings = nextDraft.settings.map((setting) => { + const change = changesById.get(setting.option_id) + const staged = stagedSettings.get(setting.option_id) + if (!change || !staged) return setting + + return { + ...setting, + ...(change.sync_enabled !== undefined ? { sync_enabled: staged.sync_enabled } : {}), + ...(change.canonical_value !== undefined + ? { + canonical_value: staged.canonical_value, + value_state: staged.value_state, + } + : {}), + validation_error: conflictOptionIds.has(setting.option_id) + ? 'changed_since_opened' + : staged.validation_error, + } + }) + + baseState.value = nextBase + draftState.value = nextDraft + touchedValueOptionIds.value = new Set( + [...touchedOptionIds].filter( + (optionId) => changesById.get(optionId)?.canonical_value !== undefined, + ), + ) + } + + async function saveDraft() { + const request = editorRequest() + if (!request || request.changes.length === 0 || !baseState.value || !draftState.value) return + + cancelPreview() + const targetInstanceId = editorInstanceId.value + const generation = ++saveGeneration + const previousBase = cloneGameSettingsState(baseState.value) + const optimisticState = cloneGameSettingsState(draftState.value) + const previouslyTouchedOptionIds = new Set(touchedValueOptionIds.value) + baseState.value = cloneGameSettingsState(optimisticState) + touchedValueOptionIds.value = new Set() + try { + await queryClient.cancelQueries({ + queryKey: gameSettingsQueryOptions(targetInstanceId).queryKey, + }) + const result = targetInstanceId + ? await save_local_game_option_changes(targetInstanceId, request) + : await save_synced_game_option_changes(request) + const refreshed = + result.state ?? + (targetInstanceId + ? await get_local_game_options_config(targetInstanceId) + : await get_synced_game_options_config()) + queryClient.setQueryData(gameSettingsQueryOptions(targetInstanceId).queryKey, refreshed) + if (targetInstanceId) { + void queryClient.invalidateQueries({ queryKey: gameSettingsKeys.synced }) + } + if (result.conflicts?.length) { + if (generation !== saveGeneration || !draftState.value) return + previewGeneration++ + const stagedDraft = cloneGameSettingsState(draftState.value) + const retainedTouchedOptionIds = new Set([ + ...previouslyTouchedOptionIds, + ...touchedValueOptionIds.value, + ]) + const stagedChanges = gameSettingChanges( + previousBase, + stagedDraft, + retainedTouchedOptionIds, + ) + const conflicts = new Set(result.conflicts) + applyChangesToRefreshedState( + refreshed, + stagedDraft, + stagedChanges, + retainedTouchedOptionIds, + conflicts, + ) + + addNotification({ + type: 'warning', + title: formatMessage(messages.conflictTitle), + text: formatMessage(messages.conflictText), + }) + if (isDirty.value) { + schedulePreview() + } + return + } + + if (generation !== saveGeneration) { + onSaved() + return + } + if (!baseState.value || !draftState.value) return + previewGeneration++ + const stagedDraft = cloneGameSettingsState(draftState.value) + const touchedSinceSave = new Set(touchedValueOptionIds.value) + const changesSinceSave = gameSettingChanges(optimisticState, stagedDraft, touchedSinceSave) + applyChangesToRefreshedState(refreshed, stagedDraft, changesSinceSave, touchedSinceSave) + if (changesSinceSave.length > 0) schedulePreview() + onSaved() + } catch (error) { + if (generation === saveGeneration && draftState.value) { + previewGeneration++ + baseState.value = previousBase + touchedValueOptionIds.value = new Set([ + ...previouslyTouchedOptionIds, + ...touchedValueOptionIds.value, + ]) + if (dirtyChanges.value.length > 0) schedulePreview() + } + throw error + } + } + + onScopeDispose(reset) + + return { + draftState, + isLocalEditor, + isDirty, + hasBlockingDraft, + loading, + loadError, + saving: saveMutation.isPending, + load, + reset, + cancelChanges, + setSyncEnabled, + setCanonicalValue, + save, + saveAndWait, + } +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/use-labels.ts b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/use-labels.ts new file mode 100644 index 0000000..d75f0b9 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/use-labels.ts @@ -0,0 +1,138 @@ +import { useDebugLogger, useVIntl } from '@modrinth/ui' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { computed, type MaybeRefOrGetter, onScopeDispose, shallowRef, toValue, watch } from 'vue' + +import { + type EditableGameSetting, + type GameSettingLocaleLabel, + get_game_setting_locale_labels, +} from '@/helpers/game-options' + +export function useGameSettingLabels( + opened: MaybeRefOrGetter, + instanceId: MaybeRefOrGetter, + settings: MaybeRefOrGetter, +) { + const debug = useDebugLogger('GameSettingLabels') + const { locale } = useVIntl() + const labels = shallowRef>({}) + const optionIds = computed(() => + toValue(settings) + .filter((setting) => setting.kind === 'external') + .map((setting) => setting.option_id) + .sort(), + ) + let generation = 0 + let stopListening: UnlistenFn | undefined + let refreshSources = false + + debug('setup', { opened: toValue(opened), instanceId: toValue(instanceId), locale: locale.value }) + + async function refresh(reason: string) { + if (!toValue(opened) || !optionIds.value.length) { + debug('refresh skipped', { + reason, + opened: toValue(opened), + optionCount: optionIds.value.length, + }) + return + } + const request = ++generation + const reindex = refreshSources + const requestedIds = [...optionIds.value] + const started = performance.now() + refreshSources = false + debug('request started', { + request, + reason, + instanceId: toValue(instanceId), + locale: locale.value, + reindex, + optionIds: requestedIds, + }) + try { + const result = await get_game_setting_locale_labels( + toValue(instanceId), + locale.value, + requestedIds, + reindex, + ) + const applied = request === generation && toValue(opened) + debug('request completed', { + request, + generation, + applied, + opened: toValue(opened), + elapsedMs: Math.round(performance.now() - started), + returnedCount: Object.keys(result.settings).length, + missingOptionIds: requestedIds.filter((id) => !result.settings[id]), + labelSample: Object.entries(result.settings).slice(0, 8), + }) + if (applied) labels.value = result.settings + } catch (error) { + debug('request failed', { + request, + reason, + elapsedMs: Math.round(performance.now() - started), + error, + }) + } + } + + watch( + () => toValue(opened), + (active, _, onCleanup) => { + debug('modal state changed', { active, generation, optionCount: optionIds.value.length }) + let cancelled = false + onCleanup(() => { + cancelled = true + stopListening?.() + stopListening = undefined + generation++ + labels.value = {} + debug('modal cleanup: listener removed and labels cleared', { generation }) + }) + if (!active) return + refreshSources = true + debug('registering locale update listener') + void listen('game-option-locales-updated', () => { + debug('received game-option-locales-updated') + void refresh('locale update event') + }) + .then((unlisten) => { + debug('locale update listener registered', { cancelled }) + if (cancelled) unlisten() + else { + stopListening = unlisten + void refresh('listener registered') + } + }) + .catch((error) => { + debug('locale update listener failed', { cancelled, error }) + if (!cancelled) void refresh('listener failed') + }) + }, + { flush: 'sync' }, + ) + + watch([locale, () => toValue(instanceId), () => optionIds.value.join('\n')], () => { + generation++ + labels.value = {} + debug('locale, instance or option IDs changed: labels cleared', { + generation, + locale: locale.value, + instanceId: toValue(instanceId), + optionCount: optionIds.value.length, + }) + void refresh('request inputs changed') + }) + + onScopeDispose(() => { + generation++ + stopListening?.() + labels.value = {} + debug('scope disposed', { generation }) + }) + + return labels +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/servers-modal.vue b/apps/app-frontend/src/components/ui/settings/instances/servers-modal.vue new file mode 100644 index 0000000..0e28d2c --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/servers-modal.vue @@ -0,0 +1,476 @@ + + + diff --git a/apps/app-frontend/src/helpers/game-options.ts b/apps/app-frontend/src/helpers/game-options.ts new file mode 100644 index 0000000..e491395 --- /dev/null +++ b/apps/app-frontend/src/helpers/game-options.ts @@ -0,0 +1,244 @@ +import { queryOptions } from '@tanstack/vue-query' +import { invoke } from '@tauri-apps/api/core' + +export type GameOptionCanonicalValue = + | { type: 'bool'; value: boolean } + | { type: 'integer'; value: number } + | { type: 'decimal'; value: string } + | { type: 'enum'; value: string } + | { type: 'text'; value: string } + | { type: 'string_list'; value: string[] } + | { type: 'key_binding'; value: string } + | { type: 'external_raw'; value: string } + +export type GameOptionValueState = 'canonical' | 'uniform_local' | 'mixed' | 'unset' | 'invalid' + +export type GameOptionCompatibilityStatus = + | 'ready' + | 'deferred' + | 'waiting_for_file' + | 'waiting_for_base' + | 'not_available' + | 'unsupported_value' + | 'unmappable' + | 'catalog_uncovered' + | 'controlled' + | 'degraded' + +export type GameOptionMappingKind = 'direct' | 'legacy' | 'migrated' | 'lossless' | 'lossy' + +export type GameOptionCompatibilityReason = + | 'launcher_controlled' + | 'catalog_uncovered' + | 'inspection_failed' + | 'waiting_for_options_file' + | 'unsupported_value' + | 'migrates_on_write' + | 'waiting_for_compatible_base' + | 'missing_setting' + +export type GameOptionCompatibilityBucket = { + instance_count: number + write_keys: string[] + eventual_keys: string[] + game_versions: string[] + status: GameOptionCompatibilityStatus + mapping?: GameOptionMappingKind | null + reason?: GameOptionCompatibilityReason | null +} + +export type GameOptionCompatibilitySummary = { + total_participating: number + will_receive: number + write_now: number + left_local: number + buckets: GameOptionCompatibilityBucket[] +} + +export type GameOptionEditorChoice = { + value: string +} + +export type GameOptionEditor = { + type: + | 'bool' + | 'integer' + | 'decimal' + | 'enum' + | 'text' + | 'string_list' + | 'key_binding' + | 'external_raw' + min?: number | null + max?: number | null + step?: number | null + unit?: string | null + choices?: GameOptionEditorChoice[] +} + +export type GameSettingCategory = { + id: string + is_custom?: boolean +} + +export type GameOptionValidationError = + 'missing_value' | 'no_compatible_instances' | 'invalid_value' | 'changed_since_opened' + +export type EditableGameSetting = { + option_id: string + category_id: string + kind: 'vanilla' | 'external' + raw_key?: string | null + sync_enabled: boolean + canonical_value?: GameOptionCanonicalValue | null + value_state: GameOptionValueState + option_revision: number + editor: GameOptionEditor + compatibility: GameOptionCompatibilitySummary + validation_error?: GameOptionValidationError | null + controlled?: boolean +} + +export type GameSettingsEditorState = { + summary_revision: string + canonical_revision: number + catalog_revision: number + total_participating: number + categories: GameSettingCategory[] + settings: EditableGameSetting[] +} + +export type GameSettingChange = { + option_id: string + base_option_revision: number + sync_enabled?: boolean + canonical_value?: GameOptionCanonicalValue | null +} + +export type UpdateGameSettingsRequest = { + expected_summary_revision: string + expected_canonical_revision: number + expected_catalog_revision: number + changes: GameSettingChange[] +} + +export type GameOptionsSourceCandidate = { + source_id: string + instance_id: string + name: string + icon_path?: string | null + game_version?: string | null + eligible: boolean + disabled_reason?: GameOptionsSourceDisabledReason | null + recognized_setting_count: number + custom_setting_count: number +} + +export type GameOptionsSourceDisabledReason = + | 'installing_or_updating' + | 'unsupported_version' + | 'missing_options_file' + | 'no_syncable_settings' + | 'unreadable_options_file' + +export type SaveGameSettingsResult = { + state?: GameSettingsEditorState + applied: number + migrated: number + deferred: number + unsupported: number + failed: number + conflicts?: string[] +} + +export type GameSettingLocaleLabel = { + label: string + choices: Record + source?: GameSettingSource | null +} + +export type GameSettingSource = { + instance_id: string + file_name: string + file_path: string + project: { + id: string + title: string + icon_url: string | null + } | null +} + +export async function get_game_setting_locale_labels( + instanceId: string | undefined, + locale: string, + optionIds: string[], + refreshSources = false, +): Promise<{ settings: Record }> { + return await invoke('plugin:instance|instance_get_game_setting_locale_labels', { + instanceId, + locale, + optionIds, + refreshSources, + }) +} + +export async function list_game_options_sync_sources(): Promise { + return await invoke('plugin:instance|instance_list_game_options_sync_sources') +} + +export async function get_synced_game_options_config(): Promise { + return await invoke('plugin:instance|instance_get_synced_game_options_config') +} + +export const gameSettingsKeys = { + synced: ['game-settings', 'synced'] as const, + local: (instanceId: string) => ['game-settings', 'local', instanceId] as const, +} + +export function gameSettingsQueryOptions(instanceId?: string) { + return queryOptions({ + queryKey: instanceId ? gameSettingsKeys.local(instanceId) : gameSettingsKeys.synced, + queryFn: () => + instanceId ? get_local_game_options_config(instanceId) : get_synced_game_options_config(), + staleTime: 30_000, + retry: false, + }) +} + +export async function preview_synced_game_option_changes( + request: UpdateGameSettingsRequest, +): Promise { + return await invoke('plugin:instance|instance_preview_synced_game_option_changes', { request }) +} + +export async function save_synced_game_option_changes( + request: UpdateGameSettingsRequest, +): Promise { + return await invoke('plugin:instance|instance_save_synced_game_option_changes', { request }) +} + +export async function get_local_game_options_config( + instanceId: string, +): Promise { + return await invoke('plugin:instance|instance_get_local_game_options_config', { instanceId }) +} + +export async function preview_local_game_option_changes( + instanceId: string, + request: UpdateGameSettingsRequest, +): Promise { + return await invoke('plugin:instance|instance_preview_local_game_option_changes', { + instanceId, + request, + }) +} + +export async function save_local_game_option_changes( + instanceId: string, + request: UpdateGameSettingsRequest, +): Promise { + return await invoke('plugin:instance|instance_save_local_game_option_changes', { + instanceId, + request, + }) +} diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts index 537dc3d..8bef6ea 100644 --- a/apps/app-frontend/src/helpers/instance.ts +++ b/apps/app-frontend/src/helpers/instance.ts @@ -108,6 +108,104 @@ export async function get_many(instanceIds: string[]): Promise { return await invoke('plugin:instance|instance_get_many', { instanceIds }) } +export function adaptContentItems(items: ContentItem[]): ContentItem[] { + return items.map((item) => { + const embeddedMetadata = item.embedded_metadata + if (!embeddedMetadata?.icon_path) return item + return { + ...item, + embedded_metadata: { + ...embeddedMetadata, + icon_url: convertFileSrc(embeddedMetadata.icon_path), + }, + } + }) +} + +export type SyncedOption = + | 'game_options' + | 'command_history' + | 'multiplayer_servers' + | 'creative_hotbars' + | 'screenshots' + | 'resource_packs' + | 'data_packs' +export type GlobalSyncedOptions = Record +export type SyncedOptionCapability = { + option: SyncedOption + supported: boolean + disabled_reason: string | null +} +export type SyncedOptionJoinPreview = { action: string } +export type SyncedOptionsOverview = { + global_options: GlobalSyncedOptions + capabilities: SyncedOptionCapability[] +} +export async function get_global_synced_options(): Promise { + return await invoke('plugin:instance|instance_get_synced_options') +} +export async function get_initialized_synced_options(): Promise { + return await invoke('plugin:instance|instance_get_initialized_synced_options') +} +/** + * Every synced option is currently available on every instance. The parameter + * stays so callers keep passing the option they are asking about, which is what + * a real implementation will need. + */ +export function isSyncedOptionAvailable(_option: SyncedOption): boolean { + return true +} +export async function set_global_synced_option( + option: SyncedOption, + enabled: boolean, + baseInstanceId?: string | null, +): Promise { + return await invoke('plugin:instance|instance_set_synced_option', { + option, + enabled, + baseInstanceId, + }) +} +export async function set_instance_synced_option( + instanceId: string, + option: SyncedOption, + enabled: boolean, + resolution?: string | null, +): Promise { + return await invoke('plugin:instance|instance_set_instance_synced_option', { + instanceId, + option, + enabled, + resolution, + }) +} +export async function get_synced_options_overview( + instanceId: string, +): Promise { + return await invoke('plugin:instance|instance_get_synced_options_overview', { instanceId }) +} +export async function get_command_history(): Promise { + return await invoke('plugin:instance|instance_get_synced_command_history') +} +export async function set_command_history(contents: string): Promise { + return await invoke('plugin:instance|instance_set_synced_command_history', { contents }) +} +export type SyncedServer = { + id: string + name: string + address: string + accept_textures: boolean | null +} +export async function list_synced_servers(): Promise { + return await invoke('plugin:instance|instance_list_synced_servers') +} +export async function update_synced_server(server: SyncedServer): Promise { + await invoke('plugin:instance|instance_update_synced_server', { server }) +} +export async function remove_synced_server(id: string): Promise { + await invoke('plugin:instance|instance_remove_synced_server', { id }) +} + export async function get_projects( instanceId: string, cacheBehaviour?: CacheBehaviour, diff --git a/apps/app-frontend/src/helpers/synced-options.ts b/apps/app-frontend/src/helpers/synced-options.ts new file mode 100644 index 0000000..cf5b9f1 --- /dev/null +++ b/apps/app-frontend/src/helpers/synced-options.ts @@ -0,0 +1,61 @@ +import { queryOptions } from '@tanstack/vue-query' + +import { list_game_options_sync_sources } from './game-options' +import { + get_command_history, + get_global_synced_options, + get_initialized_synced_options, + list_synced_servers, +} from './instance' +import type { GameInstance } from './types' + +export const syncedOptionsKeys = { + global: ['global-synced-options'] as const, + initialized: ['initialized-synced-options'] as const, + gameSources: ['game-options-sync-sources'] as const, + set: ['global-synced-options', 'set'] as const, + servers: ['synced-servers', 'list'] as const, + commandHistory: ['synced-options', 'command-history'] as const, +} + +export function globalSyncedOptionsQueryOptions() { + return queryOptions({ + queryKey: syncedOptionsKeys.global, + queryFn: get_global_synced_options, + }) +} + +export function initializedSyncedOptionsQueryOptions() { + return queryOptions({ + queryKey: syncedOptionsKeys.initialized, + queryFn: get_initialized_synced_options, + }) +} + +export function gameOptionsSyncSourcesQueryOptions() { + return queryOptions({ + queryKey: syncedOptionsKeys.gameSources, + queryFn: list_game_options_sync_sources, + staleTime: 0, + }) +} + +export function syncedServersQueryOptions() { + return queryOptions({ + queryKey: syncedOptionsKeys.servers, + queryFn: list_synced_servers, + }) +} + +export function commandHistoryQueryOptions() { + return queryOptions({ + queryKey: syncedOptionsKeys.commandHistory, + queryFn: get_command_history, + }) +} + +export function canSourceMultiplayerServers(instance: GameInstance): boolean { + return !['server_project', 'server_project_modpack', 'modrinth_hosting'].includes( + instance.link?.type ?? '', + ) +} diff --git a/apps/app-frontend/src/helpers/synced-packs.ts b/apps/app-frontend/src/helpers/synced-packs.ts new file mode 100644 index 0000000..9a86fa6 --- /dev/null +++ b/apps/app-frontend/src/helpers/synced-packs.ts @@ -0,0 +1,83 @@ +import type { ContentItem } from '@modrinth/ui' +import { queryOptions } from '@tanstack/vue-query' +import { invoke } from '@tauri-apps/api/core' + +import { adaptContentItems, isSyncedOptionAvailable } from './instance' + +export type SyncedPackType = 'resourcepack' | 'datapack' +export type SyncedPackAction = 'enable' | 'disable' + +export type DesyncServerMode = 'keep_in_other_instances' | 'remove_from_other_instances' + +export function isSyncedPackTypeAvailable(projectType: ContentItem['project_type']): boolean { + return ( + ['resourcepack', 'datapack'].includes(projectType) && + isSyncedOptionAvailable(projectType === 'resourcepack' ? 'resource_packs' : 'data_packs') + ) +} + +export interface PackSyncPreview { + pack: ContentItem + instances: { + instance_id: string + name: string + game_version: string + compatible: boolean + participating: boolean + }[] +} + +export const syncedPackKeys = { + all: ['synced-packs'] as const, + list: (type: SyncedPackType) => ['synced-packs', 'list', type] as const, +} + +export function syncedPackQueryOptions(type: SyncedPackType) { + return queryOptions({ + queryKey: syncedPackKeys.list(type), + queryFn: () => list_synced_packs(type), + enabled: isSyncedPackTypeAvailable(type), + }) +} + +export async function list_synced_packs(projectType: SyncedPackType): Promise { + const items = await invoke('plugin:instance|instance_list_synced_packs', { + projectType, + }) + return adaptContentItems(items).map((item) => ({ ...item, external: !item.project })) +} + +export function get_pack_sync_preview( + instanceId: string, + projectPath: string, +): Promise { + return invoke('plugin:instance|instance_get_pack_sync_preview', { instanceId, projectPath }) +} + +export function sync_pack(instanceId: string, projectPath: string): Promise { + return invoke('plugin:instance|instance_sync_pack', { instanceId, projectPath }) +} + +export function desync_pack( + instanceId: string, + packId: string, + mode: DesyncServerMode, +): Promise { + return invoke('plugin:instance|instance_desync_pack', { instanceId, packId, mode }) +} + +export function upload_synced_pack( + path: string, + projectType: SyncedPackType, + gameVersions: string[], +): Promise { + return invoke('plugin:instance|instance_upload_synced_pack', { path, projectType, gameVersions }) +} + +export function set_synced_pack_enabled(packId: string, enabled: boolean): Promise { + return invoke('plugin:instance|instance_set_synced_pack_enabled', { packId, enabled }) +} + +export function remove_synced_pack(packId: string): Promise { + return invoke('plugin:instance|instance_remove_synced_pack', { packId }) +} 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/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 9373038..1a596a9 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -10165,5 +10165,1268 @@ }, "settings.backups.repository.usage-description": { "message": "{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}." + }, + "app.settings.synced-options.choose-sync-source.command-history-description": { + "message": "Choose which instance to copy your command history from. This history is only used for the initial sync, and you can edit it from any instance afterward." + }, + "app.settings.synced-options.choose-sync-source.command-history-title": { + "message": "Choose command history source" + }, + "app.settings.synced-options.choose-sync-source.creative-hotbars-description": { + "message": "Choose which instance to copy your saved creative hotbars from. These hotbars are only used for the initial sync, and you can edit them from any instance afterward." + }, + "app.settings.synced-options.choose-sync-source.creative-hotbars-title": { + "message": "Choose creative hotbars source" + }, + "app.settings.synced-options.choose-sync-source.data-packs-title": { + "message": "Choose data packs source" + }, + "app.settings.synced-options.choose-sync-source.game-settings-description": { + "message": "Choose which instance to copy your game settings from. These settings are only used for the initial sync, and you can edit them from any instance afterward." + }, + "app.settings.synced-options.choose-sync-source.game-settings-title": { + "message": "Choose game settings source" + }, + "app.settings.synced-options.choose-sync-source.ineligible": { + "message": "You cannot choose this instance as a sync source" + }, + "app.settings.synced-options.choose-sync-source.load-error": { + "message": "Could not load sync sources. Please try again." + }, + "app.settings.synced-options.choose-sync-source.multiplayer-servers-description": { + "message": "Choose which instance to copy your multiplayer servers from. These servers are only used for the initial sync, and you can edit them from any instance afterward." + }, + "app.settings.synced-options.choose-sync-source.multiplayer-servers-title": { + "message": "Choose multiplayer servers source" + }, + "app.settings.synced-options.choose-sync-source.no-instances-found": { + "message": "No instances found" + }, + "app.settings.synced-options.choose-sync-source.resource-packs-title": { + "message": "Choose resource packs source" + }, + "app.settings.synced-options.choose-sync-source.retry": { + "message": "Try again" + }, + "app.settings.synced-options.choose-sync-source.search-placeholder": { + "message": "Search instance" + }, + "app.settings.synced-options.choose-sync-source.sync": { + "message": "Sync" + }, + "app.settings.synced-options.command-history": { + "message": "Sync command history" + }, + "app.settings.synced-options.command-history.description": { + "message": "Use the same command history across your instances." + }, + "app.settings.synced-options.command-history.editor-title": { + "message": "Edit command history" + }, + "app.settings.synced-options.creative-hotbars": { + "message": "Sync saved creative hotbars" + }, + "app.settings.synced-options.creative-hotbars.description": { + "message": "Use the same saved creative hotbars across your instances." + }, + "app.settings.synced-options.data-packs": { + "message": "Sync data packs" + }, + "app.settings.synced-options.data-packs.description": { + "message": "Use the same datapacks across your instances" + }, + "app.settings.synced-options.data-packs.none-synced-yet": { + "message": "You haven't synced any data packs yet" + }, + "app.settings.synced-options.edit.no-instances": { + "message": "Add an instance before editing synced data." + }, + "app.settings.synced-options.edit.no-synced-data": { + "message": "Choose a sync source before editing this setting." + }, + "app.settings.synced-options.game-settings": { + "message": "Sync game options" + }, + "app.settings.synced-options.game-settings.button": { + "message": "Edit game settings" + }, + "app.settings.synced-options.game-settings.description": { + "message": "Use the same game options across your instances." + }, + "app.settings.synced-options.game-settings.disabled-tooltip": { + "message": "Enable game settings sync before choosing individual settings." + }, + "app.settings.synced-options.game-settings.no-options-to-edit": { + "message": "You haven't got any options yet to edit" + }, + "app.settings.synced-options.multiplayer-servers": { + "message": "Sync multiplayer servers" + }, + "app.settings.synced-options.multiplayer-servers.address": { + "message": "Server address" + }, + "app.settings.synced-options.multiplayer-servers.copy-address": { + "message": "Copy address" + }, + "app.settings.synced-options.multiplayer-servers.count": { + "message": "{count, plural, one {# server} other {# servers}}" + }, + "app.settings.synced-options.multiplayer-servers.description": { + "message": "Use the same multiplayer servers across your instances." + }, + "app.settings.synced-options.multiplayer-servers.editor-title": { + "message": "Edit synced servers" + }, + "app.settings.synced-options.multiplayer-servers.empty": { + "message": "No user-added servers are currently synced." + }, + "app.settings.synced-options.multiplayer-servers.filter-offline": { + "message": "Offline" + }, + "app.settings.synced-options.multiplayer-servers.filter-online": { + "message": "Online" + }, + "app.settings.synced-options.multiplayer-servers.more-options": { + "message": "More server options" + }, + "app.settings.synced-options.multiplayer-servers.name": { + "message": "Server name" + }, + "app.settings.synced-options.multiplayer-servers.no-results": { + "message": "No synced servers match your search or filters." + }, + "app.settings.synced-options.multiplayer-servers.none-synced-yet": { + "message": "You haven't synced any servers yet" + }, + "app.settings.synced-options.multiplayer-servers.players-online": { + "message": "{count, number} online" + }, + "app.settings.synced-options.multiplayer-servers.search": { + "message": "Search {count, number} {count, plural, one {synced server} other {synced servers}}..." + }, + "app.settings.synced-options.multiplayer-servers.status": { + "message": "Status" + }, + "app.settings.synced-options.packs.disabled": { + "message": "Enable syncing before editing shared packs." + }, + "app.settings.synced-options.resource-packs": { + "message": "Sync resource packs" + }, + "app.settings.synced-options.resource-packs.description": { + "message": "Use the same resource packs across your instances." + }, + "app.settings.synced-options.resource-packs.none-synced-yet": { + "message": "You haven't synced any resource packs yet" + }, + "app.settings.synced-options.title": { + "message": "Options synced across all instances" + }, + "app.settings.synced-packs.data-packs": { + "message": "Data packs" + }, + "app.settings.synced-packs.disable": { + "message": "Disable" + }, + "app.settings.synced-packs.empty": { + "message": "No synced packs" + }, + "app.settings.synced-packs.enable": { + "message": "Enable" + }, + "app.settings.synced-packs.remove": { + "message": "Remove" + }, + "app.settings.synced-packs.resource-packs": { + "message": "Resource packs" + }, + "app.sync-instances-update.choose-source.data-packs": { + "message": "Choose which instance to copy your data packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward." + }, + "app.sync-instances-update.choose-source.resource-packs": { + "message": "Choose which instance to copy your resource packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward." + }, + "instance.settings.tabs.backups": { + "message": "Backups" + }, + "instance.settings.tabs.sync": { + "message": "Synchronization" + }, + "app.settings.game-options.category.accessibility.description": { + "message": "Accessibility preferences" + }, + "app.settings.game-options.category.accessibility.label": { + "message": "Accessibility" + }, + "app.settings.game-options.category.chat.description": { + "message": "Chat visibility and appearance" + }, + "app.settings.game-options.category.chat.label": { + "message": "Chat" + }, + "app.settings.game-options.category.controls.description": { + "message": "Mouse, movement, and key bindings" + }, + "app.settings.game-options.category.controls.label": { + "message": "Controls" + }, + "app.settings.game-options.category.custom.description": { + "message": "Settings added by mods" + }, + "app.settings.game-options.category.custom.label": { + "message": "Custom settings" + }, + "app.settings.game-options.category.language.description": { + "message": "Game language" + }, + "app.settings.game-options.category.language.label": { + "message": "Language" + }, + "app.settings.game-options.category.music-and-sound.description": { + "message": "Volume and audio preferences" + }, + "app.settings.game-options.category.music-and-sound.label": { + "message": "Music and sound" + }, + "app.settings.game-options.category.online.description": { + "message": "Online and Realms preferences" + }, + "app.settings.game-options.category.online.label": { + "message": "Online" + }, + "app.settings.game-options.category.skin-customization.description": { + "message": "Skin layers and main hand" + }, + "app.settings.game-options.category.skin-customization.label": { + "message": "Skin customization" + }, + "app.settings.game-options.category.video.description": { + "message": "Camera and display settings" + }, + "app.settings.game-options.category.video.label": { + "message": "Video" + }, + "app.settings.game-options.choice.all": { + "message": "All" + }, + "app.settings.game-options.choice.balanced": { + "message": "Balanced" + }, + "app.settings.game-options.choice.by-player": { + "message": "By player" + }, + "app.settings.game-options.choice.chat": { + "message": "Chat" + }, + "app.settings.game-options.choice.commands-only": { + "message": "Commands only" + }, + "app.settings.game-options.choice.constant": { + "message": "Constant" + }, + "app.settings.game-options.choice.crosshair": { + "message": "Crosshair" + }, + "app.settings.game-options.choice.custom": { + "message": "Custom" + }, + "app.settings.game-options.choice.decreased": { + "message": "Decreased" + }, + "app.settings.game-options.choice.default": { + "message": "Default" + }, + "app.settings.game-options.choice.fabulous": { + "message": "Fabulous" + }, + "app.settings.game-options.choice.fancy": { + "message": "Fancy" + }, + "app.settings.game-options.choice.far": { + "message": "Far" + }, + "app.settings.game-options.choice.fast": { + "message": "Fast" + }, + "app.settings.game-options.choice.frequent": { + "message": "Frequent" + }, + "app.settings.game-options.choice.hidden": { + "message": "Hidden" + }, + "app.settings.game-options.choice.hotbar": { + "message": "Hotbar" + }, + "app.settings.game-options.choice.left": { + "message": "Left" + }, + "app.settings.game-options.choice.limited": { + "message": "Limited" + }, + "app.settings.game-options.choice.max-fps": { + "message": "Max FPS" + }, + "app.settings.game-options.choice.maximum": { + "message": "Maximum" + }, + "app.settings.game-options.choice.minimal": { + "message": "Minimal" + }, + "app.settings.game-options.choice.minimum": { + "message": "Minimum" + }, + "app.settings.game-options.choice.nearby": { + "message": "Nearby" + }, + "app.settings.game-options.choice.never": { + "message": "Never" + }, + "app.settings.game-options.choice.none": { + "message": "None" + }, + "app.settings.game-options.choice.normal": { + "message": "Normal" + }, + "app.settings.game-options.choice.off": { + "message": "Off" + }, + "app.settings.game-options.choice.on": { + "message": "On" + }, + "app.settings.game-options.choice.opengl": { + "message": "OpenGL" + }, + "app.settings.game-options.choice.pause": { + "message": "Pause menu" + }, + "app.settings.game-options.choice.pause-and-toast": { + "message": "Pause menu and toast" + }, + "app.settings.game-options.choice.power-saver": { + "message": "Power saver" + }, + "app.settings.game-options.choice.right": { + "message": "Right" + }, + "app.settings.game-options.choice.short": { + "message": "Short" + }, + "app.settings.game-options.choice.shown": { + "message": "Shown" + }, + "app.settings.game-options.choice.system": { + "message": "System" + }, + "app.settings.game-options.choice.tiny": { + "message": "Tiny" + }, + "app.settings.game-options.choice.vulkan": { + "message": "Vulkan" + }, + "app.settings.game-options.choice.when-minimized": { + "message": "When minimized" + }, + "app.settings.game-options.choice.while-afk": { + "message": "While AFK" + }, + "app.settings.game-options.compatibility.none": { + "message": "Some of your instances cannot use this setting" + }, + "app.settings.game-options.compatibility.reason.launcher-controlled": { + "message": "This setting is managed by Modrinth’s launch settings." + }, + "app.settings.game-options.custom-value.placeholder": { + "message": "Enter a value" + }, + "app.settings.game-options.keybind.assigned-status": { + "message": "{setting} is now bound to {binding}." + }, + "app.settings.game-options.keybind.cancelled-status": { + "message": "Key binding change cancelled." + }, + "app.settings.game-options.keybind.change": { + "message": "{setting}: {binding}. Activate to change the binding." + }, + "app.settings.game-options.keybind.choose": { + "message": "Choose a key" + }, + "app.settings.game-options.keybind.conflict-description": { + "message": "This is already used by {settings}." + }, + "app.settings.game-options.keybind.key.arrow-down": { + "message": "Down Arrow" + }, + "app.settings.game-options.keybind.key.arrow-left": { + "message": "Left Arrow" + }, + "app.settings.game-options.keybind.key.arrow-right": { + "message": "Right Arrow" + }, + "app.settings.game-options.keybind.key.arrow-up": { + "message": "Up Arrow" + }, + "app.settings.game-options.keybind.key.backspace": { + "message": "Backspace" + }, + "app.settings.game-options.keybind.key.caps-lock": { + "message": "Caps Lock" + }, + "app.settings.game-options.keybind.key.delete": { + "message": "Delete" + }, + "app.settings.game-options.keybind.key.end": { + "message": "End" + }, + "app.settings.game-options.keybind.key.enter": { + "message": "Enter" + }, + "app.settings.game-options.keybind.key.escape": { + "message": "Escape" + }, + "app.settings.game-options.keybind.key.home": { + "message": "Home" + }, + "app.settings.game-options.keybind.key.insert": { + "message": "Insert" + }, + "app.settings.game-options.keybind.key.keypad-key": { + "message": "Numpad {key}" + }, + "app.settings.game-options.keybind.key.left-alt": { + "message": "Left Alt" + }, + "app.settings.game-options.keybind.key.left-command": { + "message": "Left Command" + }, + "app.settings.game-options.keybind.key.left-control": { + "message": "Left Ctrl" + }, + "app.settings.game-options.keybind.key.left-shift": { + "message": "Left Shift" + }, + "app.settings.game-options.keybind.key.left-super": { + "message": "Left Super" + }, + "app.settings.game-options.keybind.key.menu": { + "message": "Menu" + }, + "app.settings.game-options.keybind.key.num-lock": { + "message": "Num Lock" + }, + "app.settings.game-options.keybind.key.page-down": { + "message": "Page Down" + }, + "app.settings.game-options.keybind.key.page-up": { + "message": "Page Up" + }, + "app.settings.game-options.keybind.key.pause": { + "message": "Pause" + }, + "app.settings.game-options.keybind.key.print-screen": { + "message": "Print Screen" + }, + "app.settings.game-options.keybind.key.right-alt": { + "message": "Right Alt" + }, + "app.settings.game-options.keybind.key.right-command": { + "message": "Right Command" + }, + "app.settings.game-options.keybind.key.right-control": { + "message": "Right Ctrl" + }, + "app.settings.game-options.keybind.key.right-shift": { + "message": "Right Shift" + }, + "app.settings.game-options.keybind.key.right-super": { + "message": "Right Super" + }, + "app.settings.game-options.keybind.key.scroll-lock": { + "message": "Scroll Lock" + }, + "app.settings.game-options.keybind.key.space": { + "message": "Space" + }, + "app.settings.game-options.keybind.key.tab": { + "message": "Tab" + }, + "app.settings.game-options.keybind.key.unbound": { + "message": "Unbound" + }, + "app.settings.game-options.keybind.key.unknown": { + "message": "Unknown key" + }, + "app.settings.game-options.keybind.listen": { + "message": "Press a key…" + }, + "app.settings.game-options.keybind.listening-status": { + "message": "Listening for a key or mouse button. Press Escape to clear this binding." + }, + "app.settings.game-options.keybind.mixed": { + "message": "Different across instances" + }, + "app.settings.game-options.keybind.modifier.alt": { + "message": "Alt" + }, + "app.settings.game-options.keybind.modifier.command": { + "message": "Command" + }, + "app.settings.game-options.keybind.modifier.control": { + "message": "Ctrl" + }, + "app.settings.game-options.keybind.modifier.shift": { + "message": "Shift" + }, + "app.settings.game-options.keybind.modifier.super": { + "message": "Super" + }, + "app.settings.game-options.keybind.mouse.button": { + "message": "Mouse {button}" + }, + "app.settings.game-options.keybind.mouse.left": { + "message": "Left Mouse" + }, + "app.settings.game-options.keybind.mouse.middle": { + "message": "Middle Mouse" + }, + "app.settings.game-options.keybind.mouse.right": { + "message": "Right Mouse" + }, + "app.settings.game-options.keybind.recording-label": { + "message": "{setting}: listening for a key or mouse button. Press Escape to clear this binding." + }, + "app.settings.game-options.keybind.scancode": { + "message": "Scancode {code}" + }, + "app.settings.game-options.keybind.unsupported-status": { + "message": "That input cannot be used by Minecraft. Press another key." + }, + "app.settings.game-options.setting.advanced-opengl.label": { + "message": "Advanced OpenGL" + }, + "app.settings.game-options.setting.allow-cursor-changes.label": { + "message": "Allow cursor changes" + }, + "app.settings.game-options.setting.allow-server-listing.description": { + "message": "Allow the player's name to appear in server listings." + }, + "app.settings.game-options.setting.allow-server-listing.label": { + "message": "Server listings" + }, + "app.settings.game-options.setting.alternate-blocks.label": { + "message": "Alternate blocks" + }, + "app.settings.game-options.setting.ambient-occlusion.label": { + "message": "Smooth lighting" + }, + "app.settings.game-options.setting.ambient-volume.label": { + "message": "Ambient/Environment" + }, + "app.settings.game-options.setting.anaglyph-3d.label": { + "message": "3D anaglyph" + }, + "app.settings.game-options.setting.anisotropic-filtering.label": { + "message": "Anisotropic filtering" + }, + "app.settings.game-options.setting.anisotropy.label": { + "message": "Anisotropy" + }, + "app.settings.game-options.setting.attack-indicator.label": { + "message": "Attack indicator" + }, + "app.settings.game-options.setting.auto-jump.description": { + "message": "Automatically jump up one-block-high obstacles." + }, + "app.settings.game-options.setting.auto-jump.label": { + "message": "Auto-jump" + }, + "app.settings.game-options.setting.auto-suggestions.label": { + "message": "Command suggestions" + }, + "app.settings.game-options.setting.autosave-indicator.label": { + "message": "Autosave indicator" + }, + "app.settings.game-options.setting.biome-blend-radius.description": { + "message": "The distance over which biome colors transition." + }, + "app.settings.game-options.setting.biome-blend-radius.label": { + "message": "Biome blend" + }, + "app.settings.game-options.setting.blocks-volume.label": { + "message": "Blocks" + }, + "app.settings.game-options.setting.brightness.label": { + "message": "Brightness" + }, + "app.settings.game-options.setting.cape.description": { + "message": "Show the player's cape, including its elytra texture." + }, + "app.settings.game-options.setting.cape.label": { + "message": "Cape" + }, + "app.settings.game-options.setting.chat-background-only.label": { + "message": "Chat background only" + }, + "app.settings.game-options.setting.chat-colors.label": { + "message": "Chat colors" + }, + "app.settings.game-options.setting.chat-delay.label": { + "message": "Chat delay" + }, + "app.settings.game-options.setting.chat-line-spacing.label": { + "message": "Chat line spacing" + }, + "app.settings.game-options.setting.chat-links-prompt.description": { + "message": "Ask before opening links from chat." + }, + "app.settings.game-options.setting.chat-links-prompt.label": { + "message": "Prompt on links" + }, + "app.settings.game-options.setting.chat-links.description": { + "message": "Allow web links in chat to be opened." + }, + "app.settings.game-options.setting.chat-links.label": { + "message": "Web links" + }, + "app.settings.game-options.setting.chat-opacity.description": { + "message": "The opacity of chat text." + }, + "app.settings.game-options.setting.chat-opacity.label": { + "message": "Chat opacity" + }, + "app.settings.game-options.setting.chat-preview.label": { + "message": "Chat preview" + }, + "app.settings.game-options.setting.chat-scale.label": { + "message": "Chat scale" + }, + "app.settings.game-options.setting.chat-visibility.label": { + "message": "Chat visibility" + }, + "app.settings.game-options.setting.chat-width.label": { + "message": "Chat width" + }, + "app.settings.game-options.setting.chunk-fade-time.label": { + "message": "Chunk fade time" + }, + "app.settings.game-options.setting.cloud-range.label": { + "message": "Cloud distance" + }, + "app.settings.game-options.setting.clouds.label": { + "message": "Clouds" + }, + "app.settings.game-options.setting.ctrl-click-right-click.label": { + "message": "Control-click as right-click" + }, + "app.settings.game-options.setting.cutout-leaves.label": { + "message": "Cutout leaves" + }, + "app.settings.game-options.setting.damage-tilt.label": { + "message": "Damage tilt" + }, + "app.settings.game-options.setting.dark-splash.description": { + "message": "Change the Mojang Studios loading screen from red to black." + }, + "app.settings.game-options.setting.dark-splash.label": { + "message": "Monochrome logo" + }, + "app.settings.game-options.setting.darkness-pulsing.label": { + "message": "Darkness pulsing" + }, + "app.settings.game-options.setting.debug-gui-scale.label": { + "message": "Debug GUI scale" + }, + "app.settings.game-options.setting.directional-audio.label": { + "message": "Directional audio" + }, + "app.settings.game-options.setting.discrete-mouse-scroll.description": { + "message": "Treat each mouse-wheel input as a single scroll step." + }, + "app.settings.game-options.setting.discrete-mouse-scroll.label": { + "message": "Discrete scrolling" + }, + "app.settings.game-options.setting.entity-distance.label": { + "message": "Entity distance" + }, + "app.settings.game-options.setting.entity-shadows.label": { + "message": "Entity shadows" + }, + "app.settings.game-options.setting.exclusive-fullscreen.label": { + "message": "Exclusive fullscreen" + }, + "app.settings.game-options.setting.extra-telemetry.label": { + "message": "Optional telemetry" + }, + "app.settings.game-options.setting.focused-chat-height.label": { + "message": "Focused chat height" + }, + "app.settings.game-options.setting.force-unicode-font.label": { + "message": "Force Unicode font" + }, + "app.settings.game-options.setting.fov-effects.label": { + "message": "FOV effects" + }, + "app.settings.game-options.setting.fov.label": { + "message": "Field of view" + }, + "app.settings.game-options.setting.fullscreen.label": { + "message": "Fullscreen" + }, + "app.settings.game-options.setting.glint-speed.label": { + "message": "Glint speed" + }, + "app.settings.game-options.setting.glint-strength.label": { + "message": "Glint strength" + }, + "app.settings.game-options.setting.graphics-backend.label": { + "message": "Graphics backend" + }, + "app.settings.game-options.setting.graphics.description": { + "message": "Controls visual quality and performance." + }, + "app.settings.game-options.setting.graphics.label": { + "message": "Graphics" + }, + "app.settings.game-options.setting.gui-scale.description": { + "message": "The size of the game interface and HUD." + }, + "app.settings.game-options.setting.gui-scale.label": { + "message": "GUI scale" + }, + "app.settings.game-options.setting.hat.description": { + "message": "Show the hat skin layer." + }, + "app.settings.game-options.setting.hat.label": { + "message": "Hat" + }, + "app.settings.game-options.setting.held-item-tooltips.label": { + "message": "Held item tooltips" + }, + "app.settings.game-options.setting.hide-lightning-flashes.label": { + "message": "Hide lightning flashes" + }, + "app.settings.game-options.setting.hide-matched-names.label": { + "message": "Hide matched names" + }, + "app.settings.game-options.setting.hide-server-address.label": { + "message": "Hide server address" + }, + "app.settings.game-options.setting.hide-splash-texts.label": { + "message": "Hide splash texts" + }, + "app.settings.game-options.setting.high-contrast-outline.label": { + "message": "High contrast block outline" + }, + "app.settings.game-options.setting.high-contrast.description": { + "message": "Enhance the contrast of interface elements." + }, + "app.settings.game-options.setting.high-contrast.label": { + "message": "High contrast" + }, + "app.settings.game-options.setting.hostile-volume.label": { + "message": "Hostile creatures" + }, + "app.settings.game-options.setting.improved-transparency.label": { + "message": "Improved transparency" + }, + "app.settings.game-options.setting.in-game-notifications.label": { + "message": "In-game notifications" + }, + "app.settings.game-options.setting.inactivity-framerate-limit.label": { + "message": "Reduced framerate" + }, + "app.settings.game-options.setting.invert-horizontal-mouse.label": { + "message": "Invert horizontal mouse" + }, + "app.settings.game-options.setting.invert-mouse.description": { + "message": "Invert vertical mouse movement." + }, + "app.settings.game-options.setting.invert-mouse.label": { + "message": "Invert mouse" + }, + "app.settings.game-options.setting.jacket.description": { + "message": "Show the jacket skin layer." + }, + "app.settings.game-options.setting.jacket.label": { + "message": "Jacket" + }, + "app.settings.game-options.setting.japanese-glyph-variants.label": { + "message": "Japanese glyph variants" + }, + "app.settings.game-options.setting.key-advancements.label": { + "message": "Advancements" + }, + "app.settings.game-options.setting.key-attack.label": { + "message": "Attack" + }, + "app.settings.game-options.setting.key-back.label": { + "message": "Move backward" + }, + "app.settings.game-options.setting.key-chat.label": { + "message": "Open chat" + }, + "app.settings.game-options.setting.key-command.label": { + "message": "Command" + }, + "app.settings.game-options.setting.key-debug-advanced-tooltips.label": { + "message": "Show advanced tooltips" + }, + "app.settings.game-options.setting.key-debug-chunk-borders.label": { + "message": "Show chunk borders" + }, + "app.settings.game-options.setting.key-debug-clear-chat.label": { + "message": "Clear chat" + }, + "app.settings.game-options.setting.key-debug-copy-location.label": { + "message": "Copy location" + }, + "app.settings.game-options.setting.key-debug-copy-recreate-command.label": { + "message": "Copy recreate command" + }, + "app.settings.game-options.setting.key-debug-crash.label": { + "message": "Trigger debug crash" + }, + "app.settings.game-options.setting.key-debug-dump-dynamic-textures.label": { + "message": "Dump dynamic textures" + }, + "app.settings.game-options.setting.key-debug-dump-version.label": { + "message": "Dump version" + }, + "app.settings.game-options.setting.key-debug-focus-pause.label": { + "message": "Pause on lost focus" + }, + "app.settings.game-options.setting.key-debug-fps-charts.label": { + "message": "FPS charts" + }, + "app.settings.game-options.setting.key-debug-hitboxes.label": { + "message": "Show hitboxes" + }, + "app.settings.game-options.setting.key-debug-improved-transparency.label": { + "message": "Improved transparency debug view" + }, + "app.settings.game-options.setting.key-debug-lightmap-texture.label": { + "message": "Lightmap texture" + }, + "app.settings.game-options.setting.key-debug-modifier.label": { + "message": "Debug modifier" + }, + "app.settings.game-options.setting.key-debug-network-charts.label": { + "message": "Network charts" + }, + "app.settings.game-options.setting.key-debug-options.label": { + "message": "Debug options" + }, + "app.settings.game-options.setting.key-debug-overlay.label": { + "message": "Debug overlay" + }, + "app.settings.game-options.setting.key-debug-profiling-chart.label": { + "message": "Profiling chart" + }, + "app.settings.game-options.setting.key-debug-profiling.label": { + "message": "Start profiling" + }, + "app.settings.game-options.setting.key-debug-reload-chunks.label": { + "message": "Reload chunks" + }, + "app.settings.game-options.setting.key-debug-reload-resource-packs.label": { + "message": "Reload resource packs" + }, + "app.settings.game-options.setting.key-debug-spectate.label": { + "message": "Spectate entity" + }, + "app.settings.game-options.setting.key-debug-switch-game-mode.label": { + "message": "Switch game mode" + }, + "app.settings.game-options.setting.key-drop.label": { + "message": "Drop item" + }, + "app.settings.game-options.setting.key-forward.label": { + "message": "Move forward" + }, + "app.settings.game-options.setting.key-friends.label": { + "message": "Friends" + }, + "app.settings.game-options.setting.key-fullscreen.label": { + "message": "Toggle fullscreen" + }, + "app.settings.game-options.setting.key-hotbar-1.label": { + "message": "Hotbar 1" + }, + "app.settings.game-options.setting.key-hotbar-2.label": { + "message": "Hotbar 2" + }, + "app.settings.game-options.setting.key-hotbar-3.label": { + "message": "Hotbar 3" + }, + "app.settings.game-options.setting.key-hotbar-4.label": { + "message": "Hotbar 4" + }, + "app.settings.game-options.setting.key-hotbar-5.label": { + "message": "Hotbar 5" + }, + "app.settings.game-options.setting.key-hotbar-6.label": { + "message": "Hotbar 6" + }, + "app.settings.game-options.setting.key-hotbar-7.label": { + "message": "Hotbar 7" + }, + "app.settings.game-options.setting.key-hotbar-8.label": { + "message": "Hotbar 8" + }, + "app.settings.game-options.setting.key-hotbar-9.label": { + "message": "Hotbar 9" + }, + "app.settings.game-options.setting.key-inventory.label": { + "message": "Inventory" + }, + "app.settings.game-options.setting.key-jump.label": { + "message": "Jump" + }, + "app.settings.game-options.setting.key-left.label": { + "message": "Strafe left" + }, + "app.settings.game-options.setting.key-load-toolbar.label": { + "message": "Load toolbar" + }, + "app.settings.game-options.setting.key-perspective.label": { + "message": "Change perspective" + }, + "app.settings.game-options.setting.key-pick-item.label": { + "message": "Pick block" + }, + "app.settings.game-options.setting.key-player-list.label": { + "message": "Player list" + }, + "app.settings.game-options.setting.key-quick-actions.label": { + "message": "Quick actions" + }, + "app.settings.game-options.setting.key-right.label": { + "message": "Strafe right" + }, + "app.settings.game-options.setting.key-save-toolbar.label": { + "message": "Save toolbar" + }, + "app.settings.game-options.setting.key-screenshot.label": { + "message": "Screenshot" + }, + "app.settings.game-options.setting.key-smooth-camera.label": { + "message": "Toggle cinematic camera" + }, + "app.settings.game-options.setting.key-sneak.label": { + "message": "Sneak" + }, + "app.settings.game-options.setting.key-social-interactions.label": { + "message": "Social interactions" + }, + "app.settings.game-options.setting.key-spectator-hotbar.label": { + "message": "Spectator hotbar" + }, + "app.settings.game-options.setting.key-spectator-outlines.label": { + "message": "Highlight spectators" + }, + "app.settings.game-options.setting.key-sprint.label": { + "message": "Sprint" + }, + "app.settings.game-options.setting.key-swap-offhand.label": { + "message": "Swap offhand" + }, + "app.settings.game-options.setting.key-toggle-gui.label": { + "message": "Toggle HUD" + }, + "app.settings.game-options.setting.key-toggle-spectator-shader.label": { + "message": "Toggle spectator shader" + }, + "app.settings.game-options.setting.key-use.label": { + "message": "Use item" + }, + "app.settings.game-options.setting.language.label": { + "message": "Language" + }, + "app.settings.game-options.setting.left-pants-leg.label": { + "message": "Left pants leg" + }, + "app.settings.game-options.setting.left-sleeve.label": { + "message": "Left sleeve" + }, + "app.settings.game-options.setting.legacy-framerate-limit.label": { + "message": "Framerate limit" + }, + "app.settings.game-options.setting.legacy-view-distance.label": { + "message": "View distance" + }, + "app.settings.game-options.setting.mac-fullscreen-menu.label": { + "message": "Show macOS menu in fullscreen" + }, + "app.settings.game-options.setting.main-hand.description": { + "message": "Choose whether the main hand is left or right." + }, + "app.settings.game-options.setting.main-hand.label": { + "message": "Main hand" + }, + "app.settings.game-options.setting.master-volume.label": { + "message": "Master volume" + }, + "app.settings.game-options.setting.max-framerate.label": { + "message": "Maximum framerate" + }, + "app.settings.game-options.setting.menu-background-blur.label": { + "message": "Menu background blur" + }, + "app.settings.game-options.setting.mipmap-levels.description": { + "message": "Texture smoothing at a distance." + }, + "app.settings.game-options.setting.mipmap-levels.label": { + "message": "Mipmap levels" + }, + "app.settings.game-options.setting.mouse-wheel-sensitivity.label": { + "message": "Mouse wheel sensitivity" + }, + "app.settings.game-options.setting.music-frequency.label": { + "message": "Music frequency" + }, + "app.settings.game-options.setting.music-toast.description": { + "message": "Choose whether music titles appear in the pause menu and as toasts." + }, + "app.settings.game-options.setting.music-toast.label": { + "message": "Music notification" + }, + "app.settings.game-options.setting.music-volume.label": { + "message": "Music" + }, + "app.settings.game-options.setting.narrator-hotkey.label": { + "message": "Narrator hotkey" + }, + "app.settings.game-options.setting.narrator.description": { + "message": "Choose what the narrator reads." + }, + "app.settings.game-options.setting.narrator.label": { + "message": "Narrator" + }, + "app.settings.game-options.setting.neutral-volume.label": { + "message": "Friendly creatures" + }, + "app.settings.game-options.setting.notification-time.description": { + "message": "How long toast notifications remain visible." + }, + "app.settings.game-options.setting.notification-time.label": { + "message": "Notification time" + }, + "app.settings.game-options.setting.operator-items-tab.label": { + "message": "Operator items tab" + }, + "app.settings.game-options.setting.panorama-speed.label": { + "message": "Panorama speed" + }, + "app.settings.game-options.setting.particles.label": { + "message": "Particles" + }, + "app.settings.game-options.setting.players-volume.label": { + "message": "Players" + }, + "app.settings.game-options.setting.prioritize-chunk-updates.label": { + "message": "Prioritize chunk updates" + }, + "app.settings.game-options.setting.quit-shortcuts.label": { + "message": "Quit shortcuts" + }, + "app.settings.game-options.setting.raw-mouse-input.label": { + "message": "Raw mouse input" + }, + "app.settings.game-options.setting.realms-notifications.label": { + "message": "Realms notifications" + }, + "app.settings.game-options.setting.record-volume.label": { + "message": "Jukebox/Note Blocks" + }, + "app.settings.game-options.setting.reduced-debug-info.label": { + "message": "Reduced debug information" + }, + "app.settings.game-options.setting.render-distance.label": { + "message": "Render distance" + }, + "app.settings.game-options.setting.right-pants-leg.label": { + "message": "Right pants leg" + }, + "app.settings.game-options.setting.right-sleeve.label": { + "message": "Right sleeve" + }, + "app.settings.game-options.setting.rotate-with-minecart.label": { + "message": "Rotate with minecart" + }, + "app.settings.game-options.setting.save-chat-drafts.label": { + "message": "Save chat drafts" + }, + "app.settings.game-options.setting.screen-effects.label": { + "message": "Screen effects" + }, + "app.settings.game-options.setting.secure-chat-only.label": { + "message": "Only show secure chat" + }, + "app.settings.game-options.setting.sensitivity.label": { + "message": "Mouse sensitivity" + }, + "app.settings.game-options.setting.server-textures.label": { + "message": "Server textures" + }, + "app.settings.game-options.setting.share-presence.label": { + "message": "Share presence" + }, + "app.settings.game-options.setting.simulation-distance.description": { + "message": "How far away entities update and blocks and fluids tick." + }, + "app.settings.game-options.setting.simulation-distance.label": { + "message": "Simulation distance" + }, + "app.settings.game-options.setting.snooper.label": { + "message": "Snooper" + }, + "app.settings.game-options.setting.sprint-window.label": { + "message": "Sprint window" + }, + "app.settings.game-options.setting.subtitles.description": { + "message": "Show captions for sounds played in the game." + }, + "app.settings.game-options.setting.subtitles.label": { + "message": "Subtitles" + }, + "app.settings.game-options.setting.text-background-opacity.label": { + "message": "Text background opacity" + }, + "app.settings.game-options.setting.texture-filtering.label": { + "message": "Texture filtering" + }, + "app.settings.game-options.setting.toggle-attack.label": { + "message": "Toggle attack" + }, + "app.settings.game-options.setting.toggle-crouch.description": { + "message": "Press once to remain crouched." + }, + "app.settings.game-options.setting.toggle-crouch.label": { + "message": "Toggle crouch" + }, + "app.settings.game-options.setting.toggle-sprint.description": { + "message": "Press once to remain sprinting." + }, + "app.settings.game-options.setting.toggle-sprint.label": { + "message": "Toggle sprint" + }, + "app.settings.game-options.setting.toggle-use.label": { + "message": "Toggle use" + }, + "app.settings.game-options.setting.touchscreen.label": { + "message": "Touchscreen mode" + }, + "app.settings.game-options.setting.ui-volume.label": { + "message": "UI" + }, + "app.settings.game-options.setting.unfocused-chat-height.label": { + "message": "Unfocused chat height" + }, + "app.settings.game-options.setting.use-vbo.label": { + "message": "Use VBOs" + }, + "app.settings.game-options.setting.view-bobbing.description": { + "message": "Add a bobbing motion to the camera while walking." + }, + "app.settings.game-options.setting.view-bobbing.label": { + "message": "View bobbing" + }, + "app.settings.game-options.setting.vignette.label": { + "message": "Vignette" + }, + "app.settings.game-options.setting.voice-volume.label": { + "message": "Voice and speech" + }, + "app.settings.game-options.setting.vsync.description": { + "message": "Limit the frame rate to the display refresh rate to prevent screen tearing." + }, + "app.settings.game-options.setting.vsync.label": { + "message": "VSync" + }, + "app.settings.game-options.setting.weather-radius.label": { + "message": "Weather radius" + }, + "app.settings.game-options.setting.weather-volume.label": { + "message": "Weather" + }, + "app.settings.game-options.validation.changed-since-opened": { + "message": "This setting changed elsewhere. Check it and try again." + }, + "app.settings.game-options.validation.invalid-value": { + "message": "Choose a valid value." + }, + "app.settings.game-options.validation.missing-value": { + "message": "Choose a value first." + }, + "app.settings.game-options.validation.no-compatible-instances": { + "message": "Some of your instances cannot use this setting" + }, + "app.settings.synced-options.game-settings.conflict-text": { + "message": "We loaded the latest settings. Check your changes, then save again." + }, + "app.settings.synced-options.game-settings.conflict-title": { + "message": "These settings changed elsewhere" + }, + "app.settings.synced-options.game-settings.custom-empty": { + "message": "Settings added by mods will appear here after Modrinth finds them in one of your instances." + }, + "app.settings.synced-options.game-settings.disable-all": { + "message": "Unsync all" + }, + "app.settings.synced-options.game-settings.empty": { + "message": "No settings match your search or filter." + }, + "app.settings.synced-options.game-settings.enable-all": { + "message": "Sync all" + }, + "app.settings.synced-options.game-settings.load-failed": { + "message": "We couldn’t load your game settings." + }, + "app.settings.synced-options.game-settings.loading": { + "message": "Loading settings..." + }, + "app.settings.synced-options.game-settings.not-being-synced": { + "message": "Not being synced" + }, + "app.settings.synced-options.game-settings.retry": { + "message": "Retry" + }, + "app.settings.synced-options.game-settings.save": { + "message": "Save settings" + }, + "app.settings.synced-options.game-settings.search": { + "message": "Search settings..." + }, + "app.settings.synced-options.game-settings.sync-description": { + "message": "Synced options are applied to every instance and override the values included with a modpack." + }, + "app.settings.synced-options.game-settings.sync-setting": { + "message": "Sync {setting} across instances" + }, + "app.settings.synced-options.game-settings.sync-title": { + "message": "Sync game settings" + }, + "app.settings.synced-options.game-settings.synced-across-instances": { + "message": "Synced across instances" + }, + "app.settings.synced-options.game-settings.title": { + "message": "Game settings" + }, + "app.settings.synced-options.game-settings.unsaved-changes": { + "message": "You have unsaved changes." + }, + "app.settings.synced-options.game-settings.unsync-setting": { + "message": "Stop syncing {setting} across instances" + }, + "app.settings.synced-options.game-settings.value-mixed": { + "message": "Different across instances" + }, + "app.settings.synced-options.game-settings.value-off": { + "message": "Off" + }, + "app.settings.synced-options.game-settings.value-on": { + "message": "On" + }, + "app.settings.synced-options.game-settings.value-unset": { + "message": "Choose a value" } } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index a2ffa43..3fd5974 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -10929,5 +10929,426 @@ }, "settings.backups.repository.usage-description": { "message": "实际存储 {stored},对应 {logical} 的备份数据,共 {count, plural, other {# 个快照}}。" - } + }, + "app.settings.synced-options.choose-sync-source.command-history-description": { "message": "选择要从哪个实例复制命令历史记录。这些记录仅用于初始同步,之后可从任意实例编辑。" }, + "app.settings.synced-options.choose-sync-source.command-history-title": { "message": "选择命令历史记录来源" }, + "app.settings.synced-options.choose-sync-source.creative-hotbars-description": { "message": "选择要从哪个实例复制保存的创造模式快捷栏。这些快捷栏仅用于初始同步,之后可从任意实例编辑。" }, + "app.settings.synced-options.choose-sync-source.creative-hotbars-title": { "message": "选择创造模式快捷栏来源" }, + "app.settings.synced-options.choose-sync-source.data-packs-title": { "message": "选择数据包来源" }, + "app.settings.synced-options.choose-sync-source.game-settings-description": { "message": "选择要从哪个实例复制游戏设置。这些设置仅用于初始同步,之后可从任意实例编辑。" }, + "app.settings.synced-options.choose-sync-source.game-settings-title": { "message": "选择游戏设置来源" }, + "app.settings.synced-options.choose-sync-source.ineligible": { "message": "无法选择此实例作为同步来源" }, + "app.settings.synced-options.choose-sync-source.load-error": { "message": "无法加载同步来源,请重试。" }, + "app.settings.synced-options.choose-sync-source.multiplayer-servers-description": { "message": "选择要从哪个实例复制多人服务器列表。这些服务器仅用于初始同步,之后可从任意实例编辑。" }, + "app.settings.synced-options.choose-sync-source.multiplayer-servers-title": { "message": "选择多人服务器来源" }, + "app.settings.synced-options.choose-sync-source.no-instances-found": { "message": "没有找到实例" }, + "app.settings.synced-options.choose-sync-source.resource-packs-title": { "message": "选择资源包来源" }, + "app.settings.synced-options.choose-sync-source.retry": { "message": "重试" }, + "app.settings.synced-options.choose-sync-source.search-placeholder": { "message": "搜索实例" }, + "app.settings.synced-options.choose-sync-source.sync": { "message": "同步" }, + "app.settings.synced-options.command-history": { "message": "同步命令记录" }, + "app.settings.synced-options.command-history.description": { "message": "在不同的实例之间同步命令记录。" }, + "app.settings.synced-options.command-history.editor-title": { "message": "编辑指令历史记录" }, + "app.settings.synced-options.creative-hotbars": { "message": "同步保存的创造模式物品栏" }, + "app.settings.synced-options.creative-hotbars.description": { "message": "在不同的实例之间同步创造模式物品栏。" }, + "app.settings.synced-options.data-packs": { "message": "同步数据包" }, + "app.settings.synced-options.data-packs.description": { "message": "在所有实例间使用相同的数据包" }, + "app.settings.synced-options.data-packs.none-synced-yet": { "message": "尚未同步任何数据包" }, + "app.settings.synced-options.edit.no-instances": { "message": "请先添加实例,再编辑同步数据。" }, + "app.settings.synced-options.edit.no-synced-data": { "message": "请先选择同步来源,再编辑此设置。" }, + "app.settings.synced-options.game-settings": { "message": "同步游戏设置" }, + "app.settings.synced-options.game-settings.button": { "message": "编辑游戏设置" }, + "app.settings.synced-options.game-settings.description": { "message": "在所有实例间使用相同的游戏设置。" }, + "app.settings.synced-options.game-settings.disabled-tooltip": { "message": "请先启用游戏设置同步,再选择单个设置。" }, + "app.settings.synced-options.game-settings.no-options-to-edit": { "message": "目前没有可编辑的设置" }, + "app.settings.synced-options.multiplayer-servers": { "message": "同步多人模式服务器列表" }, + "app.settings.synced-options.multiplayer-servers.address": { "message": "服务器地址" }, + "app.settings.synced-options.multiplayer-servers.copy-address": { "message": "复制地址" }, + "app.settings.synced-options.multiplayer-servers.count": { "message": "{count, number} 个服务器" }, + "app.settings.synced-options.multiplayer-servers.description": { "message": "在不同的实例直接同步多人模式服务器列表。" }, + "app.settings.synced-options.multiplayer-servers.editor-title": { "message": "编辑同步的服务器" }, + "app.settings.synced-options.multiplayer-servers.empty": { "message": "当前未同步任何用户添加的服务器。" }, + "app.settings.synced-options.multiplayer-servers.filter-offline": { "message": "离线" }, + "app.settings.synced-options.multiplayer-servers.filter-online": { "message": "在线" }, + "app.settings.synced-options.multiplayer-servers.more-options": { "message": "更多服务器选项" }, + "app.settings.synced-options.multiplayer-servers.name": { "message": "服务器名称" }, + "app.settings.synced-options.multiplayer-servers.no-results": { "message": "没有匹配搜索或筛选条件的已同步服务器。" }, + "app.settings.synced-options.multiplayer-servers.none-synced-yet": { "message": "暂无已同步的服务器" }, + "app.settings.synced-options.multiplayer-servers.players-online": { "message": "{count, number} 人在线" }, + "app.settings.synced-options.multiplayer-servers.search": { "message": "搜索 {count, number} 个已同步服务器……" }, + "app.settings.synced-options.multiplayer-servers.status": { "message": "状态" }, + "app.settings.synced-options.packs.disabled": { "message": "请先启用同步,再编辑共享包。" }, + "app.settings.synced-options.resource-packs": { "message": "同步资源包" }, + "app.settings.synced-options.resource-packs.description": { "message": "在所有实例间使用相同的资源包" }, + "app.settings.synced-options.resource-packs.none-synced-yet": { "message": "尚未同步任何资源包" }, + "app.settings.synced-options.title": { "message": "跨所有实例同步的选项" }, + "app.settings.synced-packs.data-packs": { "message": "数据包" }, + "app.settings.synced-packs.disable": { "message": "禁用" }, + "app.settings.synced-packs.empty": { "message": "没有已同步的包" }, + "app.settings.synced-packs.enable": { "message": "启用" }, + "app.settings.synced-packs.remove": { "message": "移除" }, + "app.settings.synced-packs.resource-packs": { "message": "资源包" }, + "app.sync-instances-update.choose-source.data-packs": { "message": "选择要从哪个实例复制数据包。这些包仅用于初始化同步,之后可从任意实例进行编辑。" }, + "app.sync-instances-update.choose-source.resource-packs": { "message": "选择要从哪个实例复制资源包。这些包仅用于初始化同步,之后可从任意实例进行编辑。" }, + "instance.settings.tabs.backups": { "message": "备份" }, + "instance.settings.tabs.sync": { "message": "同步" }, + "app.settings.game-options.category.accessibility.description": { "message": "无障碍设置" }, + "app.settings.game-options.category.accessibility.label": { "message": "无障碍" }, + "app.settings.game-options.category.chat.description": { "message": "聊天显示与外观" }, + "app.settings.game-options.category.chat.label": { "message": "聊天" }, + "app.settings.game-options.category.controls.description": { "message": "鼠标、移动与按键绑定" }, + "app.settings.game-options.category.controls.label": { "message": "控制" }, + "app.settings.game-options.category.custom.description": { "message": "由模组添加的设置" }, + "app.settings.game-options.category.custom.label": { "message": "自定义设置" }, + "app.settings.game-options.category.language.description": { "message": "游戏语言" }, + "app.settings.game-options.category.language.label": { "message": "语言" }, + "app.settings.game-options.category.music-and-sound.description": { "message": "音量与音频偏好" }, + "app.settings.game-options.category.music-and-sound.label": { "message": "音乐和声音" }, + "app.settings.game-options.category.online.description": { "message": "在线与 Realms 偏好" }, + "app.settings.game-options.category.online.label": { "message": "在线" }, + "app.settings.game-options.category.skin-customization.description": { "message": "皮肤层与主手" }, + "app.settings.game-options.category.skin-customization.label": { "message": "皮肤自定义" }, + "app.settings.game-options.category.video.description": { "message": "镜头与显示设置" }, + "app.settings.game-options.category.video.label": { "message": "视频" }, + "app.settings.game-options.choice.all": { "message": "全部" }, + "app.settings.game-options.choice.balanced": { "message": "平衡" }, + "app.settings.game-options.choice.by-player": { "message": "按玩家" }, + "app.settings.game-options.choice.chat": { "message": "聊天" }, + "app.settings.game-options.choice.commands-only": { "message": "仅命令" }, + "app.settings.game-options.choice.constant": { "message": "持续" }, + "app.settings.game-options.choice.crosshair": { "message": "准星" }, + "app.settings.game-options.choice.custom": { "message": "自定义" }, + "app.settings.game-options.choice.decreased": { "message": "降低" }, + "app.settings.game-options.choice.default": { "message": "默认" }, + "app.settings.game-options.choice.fabulous": { "message": "极佳" }, + "app.settings.game-options.choice.fancy": { "message": "精美" }, + "app.settings.game-options.choice.far": { "message": "远" }, + "app.settings.game-options.choice.fast": { "message": "快速" }, + "app.settings.game-options.choice.frequent": { "message": "频繁" }, + "app.settings.game-options.choice.hidden": { "message": "隐藏" }, + "app.settings.game-options.choice.hotbar": { "message": "快捷栏" }, + "app.settings.game-options.choice.left": { "message": "左" }, + "app.settings.game-options.choice.limited": { "message": "有限" }, + "app.settings.game-options.choice.max-fps": { "message": "最高 FPS" }, + "app.settings.game-options.choice.maximum": { "message": "最大" }, + "app.settings.game-options.choice.minimal": { "message": "最少" }, + "app.settings.game-options.choice.minimum": { "message": "最小" }, + "app.settings.game-options.choice.nearby": { "message": "附近" }, + "app.settings.game-options.choice.never": { "message": "从不" }, + "app.settings.game-options.choice.none": { "message": "无" }, + "app.settings.game-options.choice.normal": { "message": "正常" }, + "app.settings.game-options.choice.off": { "message": "关闭" }, + "app.settings.game-options.choice.on": { "message": "开启" }, + "app.settings.game-options.choice.opengl": { "message": "OpenGL 渲染器" }, + "app.settings.game-options.choice.pause": { "message": "暂停菜单" }, + "app.settings.game-options.choice.pause-and-toast": { "message": "暂停菜单和提示" }, + "app.settings.game-options.choice.power-saver": { "message": "省电" }, + "app.settings.game-options.choice.right": { "message": "右" }, + "app.settings.game-options.choice.short": { "message": "短" }, + "app.settings.game-options.choice.shown": { "message": "显示" }, + "app.settings.game-options.choice.system": { "message": "系统" }, + "app.settings.game-options.choice.tiny": { "message": "极小" }, + "app.settings.game-options.choice.vulkan": { "message": "Vulkan 渲染器" }, + "app.settings.game-options.choice.when-minimized": { "message": "最小化时" }, + "app.settings.game-options.choice.while-afk": { "message": "挂机时" }, + "app.settings.game-options.compatibility.none": { "message": "部分实例无法使用此设置" }, + "app.settings.game-options.compatibility.reason.launcher-controlled": { "message": "此设置由启动器设置管理" }, + "app.settings.game-options.custom-value.placeholder": { "message": "输入值" }, + "app.settings.game-options.keybind.assigned-status": { "message": "已将 {setting} 绑定为 {binding}。" }, + "app.settings.game-options.keybind.cancelled-status": { "message": "已取消按键绑定更改。" }, + "app.settings.game-options.keybind.change": { "message": "{setting}:{binding}。激活以更改绑定。" }, + "app.settings.game-options.keybind.choose": { "message": "选择按键" }, + "app.settings.game-options.keybind.conflict-description": { "message": "{settings} 已使用此按键。" }, + "app.settings.game-options.keybind.key.arrow-down": { "message": "向下箭头" }, + "app.settings.game-options.keybind.key.arrow-left": { "message": "向左箭头" }, + "app.settings.game-options.keybind.key.arrow-right": { "message": "向右箭头" }, + "app.settings.game-options.keybind.key.arrow-up": { "message": "向上箭头" }, + "app.settings.game-options.keybind.key.backspace": { "message": "退格" }, + "app.settings.game-options.keybind.key.caps-lock": { "message": "大写锁定" }, + "app.settings.game-options.keybind.key.delete": { "message": "删除" }, + "app.settings.game-options.keybind.key.end": { "message": "End 键" }, + "app.settings.game-options.keybind.key.enter": { "message": "回车" }, + "app.settings.game-options.keybind.key.escape": { "message": "Esc 键" }, + "app.settings.game-options.keybind.key.home": { "message": "Home 键" }, + "app.settings.game-options.keybind.key.insert": { "message": "Insert 键" }, + "app.settings.game-options.keybind.key.keypad-key": { "message": "数字键盘 {key}" }, + "app.settings.game-options.keybind.key.left-alt": { "message": "左 Alt" }, + "app.settings.game-options.keybind.key.left-command": { "message": "左 Command" }, + "app.settings.game-options.keybind.key.left-control": { "message": "左 Ctrl" }, + "app.settings.game-options.keybind.key.left-shift": { "message": "左 Shift" }, + "app.settings.game-options.keybind.key.left-super": { "message": "左 Super" }, + "app.settings.game-options.keybind.key.menu": { "message": "菜单" }, + "app.settings.game-options.keybind.key.num-lock": { "message": "数字锁定" }, + "app.settings.game-options.keybind.key.page-down": { "message": "Page Down 键" }, + "app.settings.game-options.keybind.key.page-up": { "message": "Page Up 键" }, + "app.settings.game-options.keybind.key.pause": { "message": "暂停" }, + "app.settings.game-options.keybind.key.print-screen": { "message": "截图" }, + "app.settings.game-options.keybind.key.right-alt": { "message": "右 Alt" }, + "app.settings.game-options.keybind.key.right-command": { "message": "右 Command" }, + "app.settings.game-options.keybind.key.right-control": { "message": "右 Ctrl" }, + "app.settings.game-options.keybind.key.right-shift": { "message": "右 Shift" }, + "app.settings.game-options.keybind.key.right-super": { "message": "右 Super" }, + "app.settings.game-options.keybind.key.scroll-lock": { "message": "滚动锁定" }, + "app.settings.game-options.keybind.key.space": { "message": "空格" }, + "app.settings.game-options.keybind.key.tab": { "message": "Tab 键" }, + "app.settings.game-options.keybind.key.unbound": { "message": "未绑定" }, + "app.settings.game-options.keybind.key.unknown": { "message": "未知按键" }, + "app.settings.game-options.keybind.listen": { "message": "请按下一个按键……" }, + "app.settings.game-options.keybind.listening-status": { "message": "正在监听按键或鼠标按钮。按 Esc 清除绑定。" }, + "app.settings.game-options.keybind.mixed": { "message": "各实例不同" }, + "app.settings.game-options.keybind.modifier.alt": { "message": "Alt 键" }, + "app.settings.game-options.keybind.modifier.command": { "message": "Command 键" }, + "app.settings.game-options.keybind.modifier.control": { "message": "Ctrl 键" }, + "app.settings.game-options.keybind.modifier.shift": { "message": "Shift 键" }, + "app.settings.game-options.keybind.modifier.super": { "message": "Super 键" }, + "app.settings.game-options.keybind.mouse.button": { "message": "鼠标 {button}" }, + "app.settings.game-options.keybind.mouse.left": { "message": "鼠标左键" }, + "app.settings.game-options.keybind.mouse.middle": { "message": "鼠标中键" }, + "app.settings.game-options.keybind.mouse.right": { "message": "鼠标右键" }, + "app.settings.game-options.keybind.recording-label": { "message": "{setting}:正在监听按键或鼠标按钮。按 Esc 清除绑定。" }, + "app.settings.game-options.keybind.scancode": { "message": "扫描码 {code}" }, + "app.settings.game-options.keybind.unsupported-status": { "message": "Minecraft 无法使用此输入,请尝试其他按键。" }, + "app.settings.game-options.setting.advanced-opengl.label": { "message": "高级 OpenGL" }, + "app.settings.game-options.setting.allow-cursor-changes.label": { "message": "允许光标变化" }, + "app.settings.game-options.setting.allow-server-listing.description": { "message": "允许玩家名称出现在服务器列表中。" }, + "app.settings.game-options.setting.allow-server-listing.label": { "message": "服务器列表" }, + "app.settings.game-options.setting.alternate-blocks.label": { "message": "备用方块" }, + "app.settings.game-options.setting.ambient-occlusion.label": { "message": "平滑光照" }, + "app.settings.game-options.setting.ambient-volume.label": { "message": "环境" }, + "app.settings.game-options.setting.anaglyph-3d.label": { "message": "3D 红蓝立体" }, + "app.settings.game-options.setting.anisotropic-filtering.label": { "message": "各向异性过滤" }, + "app.settings.game-options.setting.anisotropy.label": { "message": "各向异性" }, + "app.settings.game-options.setting.attack-indicator.label": { "message": "攻击指示器" }, + "app.settings.game-options.setting.auto-jump.description": { "message": "自动跳过一格高的障碍物。" }, + "app.settings.game-options.setting.auto-jump.label": { "message": "自动跳跃" }, + "app.settings.game-options.setting.auto-suggestions.label": { "message": "命令建议" }, + "app.settings.game-options.setting.autosave-indicator.label": { "message": "自动保存指示器" }, + "app.settings.game-options.setting.biome-blend-radius.description": { "message": "生物群系颜色过渡的距离。" }, + "app.settings.game-options.setting.biome-blend-radius.label": { "message": "生物群系混合" }, + "app.settings.game-options.setting.blocks-volume.label": { "message": "方块" }, + "app.settings.game-options.setting.brightness.label": { "message": "亮度" }, + "app.settings.game-options.setting.cape.description": { "message": "显示玩家的披风,包括鞘翅纹理。" }, + "app.settings.game-options.setting.cape.label": { "message": "披风" }, + "app.settings.game-options.setting.chat-background-only.label": { "message": "仅聊天背景" }, + "app.settings.game-options.setting.chat-colors.label": { "message": "聊天颜色" }, + "app.settings.game-options.setting.chat-delay.label": { "message": "聊天延迟" }, + "app.settings.game-options.setting.chat-line-spacing.label": { "message": "聊天行间距" }, + "app.settings.game-options.setting.chat-links-prompt.description": { "message": "打开聊天链接前询问" }, + "app.settings.game-options.setting.chat-links-prompt.label": { "message": "打开链接前询问" }, + "app.settings.game-options.setting.chat-links.description": { "message": "允许打开聊天中的网页链接。" }, + "app.settings.game-options.setting.chat-links.label": { "message": "网页链接" }, + "app.settings.game-options.setting.chat-opacity.description": { "message": "聊天文字的不透明度。" }, + "app.settings.game-options.setting.chat-opacity.label": { "message": "聊天不透明度" }, + "app.settings.game-options.setting.chat-preview.label": { "message": "聊天预览" }, + "app.settings.game-options.setting.chat-scale.label": { "message": "聊天缩放" }, + "app.settings.game-options.setting.chat-visibility.label": { "message": "聊天可见性" }, + "app.settings.game-options.setting.chat-width.label": { "message": "聊天宽度" }, + "app.settings.game-options.setting.chunk-fade-time.label": { "message": "区块淡出时间" }, + "app.settings.game-options.setting.cloud-range.label": { "message": "云层距离" }, + "app.settings.game-options.setting.clouds.label": { "message": "云" }, + "app.settings.game-options.setting.ctrl-click-right-click.label": { "message": "按住 Control 点击代替右键点击" }, + "app.settings.game-options.setting.cutout-leaves.label": { "message": "半透明树叶" }, + "app.settings.game-options.setting.damage-tilt.label": { "message": "受伤倾斜" }, + "app.settings.game-options.setting.dark-splash.description": { "message": "将 Mojang Studios 加载界面从红色改为黑色。" }, + "app.settings.game-options.setting.dark-splash.label": { "message": "单色徽标" }, + "app.settings.game-options.setting.darkness-pulsing.label": { "message": "黑暗脉动" }, + "app.settings.game-options.setting.debug-gui-scale.label": { "message": "调试界面缩放" }, + "app.settings.game-options.setting.directional-audio.label": { "message": "方向性音频" }, + "app.settings.game-options.setting.discrete-mouse-scroll.description": { "message": "将每次鼠标滚轮输入视为一个滚动步进。" }, + "app.settings.game-options.setting.discrete-mouse-scroll.label": { "message": "离散滚动" }, + "app.settings.game-options.setting.entity-distance.label": { "message": "实体距离" }, + "app.settings.game-options.setting.entity-shadows.label": { "message": "实体阴影" }, + "app.settings.game-options.setting.exclusive-fullscreen.label": { "message": "独占全屏" }, + "app.settings.game-options.setting.extra-telemetry.label": { "message": "可选遥测" }, + "app.settings.game-options.setting.focused-chat-height.label": { "message": "聚焦时聊天高度" }, + "app.settings.game-options.setting.force-unicode-font.label": { "message": "强制 Unicode 字体" }, + "app.settings.game-options.setting.fov-effects.label": { "message": "视野效果" }, + "app.settings.game-options.setting.fov.label": { "message": "视野" }, + "app.settings.game-options.setting.fullscreen.label": { "message": "全屏" }, + "app.settings.game-options.setting.glint-speed.label": { "message": "附魔光效速度" }, + "app.settings.game-options.setting.glint-strength.label": { "message": "附魔光效强度" }, + "app.settings.game-options.setting.graphics-backend.label": { "message": "图形后端" }, + "app.settings.game-options.setting.graphics.description": { "message": "控制画面质量和性能。" }, + "app.settings.game-options.setting.graphics.label": { "message": "画面" }, + "app.settings.game-options.setting.gui-scale.description": { "message": "游戏界面和 HUD 的大小。" }, + "app.settings.game-options.setting.gui-scale.label": { "message": "界面缩放" }, + "app.settings.game-options.setting.hat.description": { "message": "显示帽子皮肤层。" }, + "app.settings.game-options.setting.hat.label": { "message": "帽子" }, + "app.settings.game-options.setting.held-item-tooltips.label": { "message": "手持物品提示" }, + "app.settings.game-options.setting.hide-lightning-flashes.label": { "message": "隐藏闪电闪光" }, + "app.settings.game-options.setting.hide-matched-names.label": { "message": "隐藏匹配的名称" }, + "app.settings.game-options.setting.hide-server-address.label": { "message": "隐藏服务器地址" }, + "app.settings.game-options.setting.hide-splash-texts.label": { "message": "隐藏启动标语" }, + "app.settings.game-options.setting.high-contrast-outline.label": { "message": "高对比度方块轮廓" }, + "app.settings.game-options.setting.high-contrast.description": { "message": "增强界面元素的对比度。" }, + "app.settings.game-options.setting.high-contrast.label": { "message": "高对比度" }, + "app.settings.game-options.setting.hostile-volume.label": { "message": "敌对生物" }, + "app.settings.game-options.setting.improved-transparency.label": { "message": "改进透明度" }, + "app.settings.game-options.setting.in-game-notifications.label": { "message": "游戏内通知" }, + "app.settings.game-options.setting.inactivity-framerate-limit.label": { "message": "降低帧率" }, + "app.settings.game-options.setting.invert-horizontal-mouse.label": { "message": "反转水平鼠标" }, + "app.settings.game-options.setting.invert-mouse.description": { "message": "反转垂直鼠标移动。" }, + "app.settings.game-options.setting.invert-mouse.label": { "message": "反转鼠标" }, + "app.settings.game-options.setting.jacket.description": { "message": "显示夹克皮肤层。" }, + "app.settings.game-options.setting.jacket.label": { "message": "夹克" }, + "app.settings.game-options.setting.japanese-glyph-variants.label": { "message": "日文字符变体" }, + "app.settings.game-options.setting.key-advancements.label": { "message": "进度" }, + "app.settings.game-options.setting.key-attack.label": { "message": "攻击" }, + "app.settings.game-options.setting.key-back.label": { "message": "后退" }, + "app.settings.game-options.setting.key-chat.label": { "message": "打开聊天" }, + "app.settings.game-options.setting.key-command.label": { "message": "命令" }, + "app.settings.game-options.setting.key-debug-advanced-tooltips.label": { "message": "显示高级提示" }, + "app.settings.game-options.setting.key-debug-chunk-borders.label": { "message": "显示区块边界" }, + "app.settings.game-options.setting.key-debug-clear-chat.label": { "message": "清空聊天" }, + "app.settings.game-options.setting.key-debug-copy-location.label": { "message": "复制位置" }, + "app.settings.game-options.setting.key-debug-copy-recreate-command.label": { "message": "复制重建命令" }, + "app.settings.game-options.setting.key-debug-crash.label": { "message": "触发调试崩溃" }, + "app.settings.game-options.setting.key-debug-dump-dynamic-textures.label": { "message": "转储动态纹理" }, + "app.settings.game-options.setting.key-debug-dump-version.label": { "message": "转储版本" }, + "app.settings.game-options.setting.key-debug-focus-pause.label": { "message": "失去焦点时暂停" }, + "app.settings.game-options.setting.key-debug-fps-charts.label": { "message": "FPS 图表" }, + "app.settings.game-options.setting.key-debug-hitboxes.label": { "message": "显示碰撞箱" }, + "app.settings.game-options.setting.key-debug-improved-transparency.label": { "message": "改进透明度调试视图" }, + "app.settings.game-options.setting.key-debug-lightmap-texture.label": { "message": "光照贴图纹理" }, + "app.settings.game-options.setting.key-debug-modifier.label": { "message": "调试修饰键" }, + "app.settings.game-options.setting.key-debug-network-charts.label": { "message": "网络图表" }, + "app.settings.game-options.setting.key-debug-options.label": { "message": "调试选项" }, + "app.settings.game-options.setting.key-debug-overlay.label": { "message": "调试覆盖层" }, + "app.settings.game-options.setting.key-debug-profiling-chart.label": { "message": "性能分析图表" }, + "app.settings.game-options.setting.key-debug-profiling.label": { "message": "开始性能分析" }, + "app.settings.game-options.setting.key-debug-reload-chunks.label": { "message": "重新加载区块" }, + "app.settings.game-options.setting.key-debug-reload-resource-packs.label": { "message": "重新加载资源包" }, + "app.settings.game-options.setting.key-debug-spectate.label": { "message": "旁观实体" }, + "app.settings.game-options.setting.key-debug-switch-game-mode.label": { "message": "切换游戏模式" }, + "app.settings.game-options.setting.key-drop.label": { "message": "丢弃物品" }, + "app.settings.game-options.setting.key-forward.label": { "message": "前进" }, + "app.settings.game-options.setting.key-friends.label": { "message": "好友" }, + "app.settings.game-options.setting.key-fullscreen.label": { "message": "切换全屏" }, + "app.settings.game-options.setting.key-hotbar-1.label": { "message": "快捷栏 1" }, + "app.settings.game-options.setting.key-hotbar-2.label": { "message": "快捷栏 2" }, + "app.settings.game-options.setting.key-hotbar-3.label": { "message": "快捷栏 3" }, + "app.settings.game-options.setting.key-hotbar-4.label": { "message": "快捷栏 4" }, + "app.settings.game-options.setting.key-hotbar-5.label": { "message": "快捷栏 5" }, + "app.settings.game-options.setting.key-hotbar-6.label": { "message": "快捷栏 6" }, + "app.settings.game-options.setting.key-hotbar-7.label": { "message": "快捷栏 7" }, + "app.settings.game-options.setting.key-hotbar-8.label": { "message": "快捷栏 8" }, + "app.settings.game-options.setting.key-hotbar-9.label": { "message": "快捷栏 9" }, + "app.settings.game-options.setting.key-inventory.label": { "message": "物品栏" }, + "app.settings.game-options.setting.key-jump.label": { "message": "跳跃" }, + "app.settings.game-options.setting.key-left.label": { "message": "向左平移" }, + "app.settings.game-options.setting.key-load-toolbar.label": { "message": "加载工具栏" }, + "app.settings.game-options.setting.key-perspective.label": { "message": "切换视角" }, + "app.settings.game-options.setting.key-pick-item.label": { "message": "选取方块" }, + "app.settings.game-options.setting.key-player-list.label": { "message": "玩家列表" }, + "app.settings.game-options.setting.key-quick-actions.label": { "message": "快捷操作" }, + "app.settings.game-options.setting.key-right.label": { "message": "向右平移" }, + "app.settings.game-options.setting.key-save-toolbar.label": { "message": "保存工具栏" }, + "app.settings.game-options.setting.key-screenshot.label": { "message": "截图" }, + "app.settings.game-options.setting.key-smooth-camera.label": { "message": "切换电影视角" }, + "app.settings.game-options.setting.key-sneak.label": { "message": "潜行" }, + "app.settings.game-options.setting.key-social-interactions.label": { "message": "社交互动" }, + "app.settings.game-options.setting.key-spectator-hotbar.label": { "message": "旁观者快捷栏" }, + "app.settings.game-options.setting.key-spectator-outlines.label": { "message": "高亮旁观者" }, + "app.settings.game-options.setting.key-sprint.label": { "message": "疾跑" }, + "app.settings.game-options.setting.key-swap-offhand.label": { "message": "交换副手" }, + "app.settings.game-options.setting.key-toggle-gui.label": { "message": "切换 HUD" }, + "app.settings.game-options.setting.key-toggle-spectator-shader.label": { "message": "切换旁观者着色器" }, + "app.settings.game-options.setting.key-use.label": { "message": "使用物品" }, + "app.settings.game-options.setting.language.label": { "message": "语言" }, + "app.settings.game-options.setting.left-pants-leg.label": { "message": "左裤腿" }, + "app.settings.game-options.setting.left-sleeve.label": { "message": "左袖" }, + "app.settings.game-options.setting.legacy-framerate-limit.label": { "message": "帧率上限" }, + "app.settings.game-options.setting.legacy-view-distance.label": { "message": "视距" }, + "app.settings.game-options.setting.mac-fullscreen-menu.label": { "message": "全屏时显示 macOS 菜单" }, + "app.settings.game-options.setting.main-hand.description": { "message": "选择主手为左手还是右手。" }, + "app.settings.game-options.setting.main-hand.label": { "message": "主手" }, + "app.settings.game-options.setting.master-volume.label": { "message": "主音量" }, + "app.settings.game-options.setting.max-framerate.label": { "message": "最大帧率" }, + "app.settings.game-options.setting.menu-background-blur.label": { "message": "菜单背景模糊" }, + "app.settings.game-options.setting.mipmap-levels.description": { "message": "平滑远处纹理。" }, + "app.settings.game-options.setting.mipmap-levels.label": { "message": "Mipmap 级别" }, + "app.settings.game-options.setting.mouse-wheel-sensitivity.label": { "message": "鼠标滚轮灵敏度" }, + "app.settings.game-options.setting.music-frequency.label": { "message": "音乐频率" }, + "app.settings.game-options.setting.music-toast.description": { "message": "选择是否在暂停菜单和提示中显示音乐标题。" }, + "app.settings.game-options.setting.music-toast.label": { "message": "音乐通知" }, + "app.settings.game-options.setting.music-volume.label": { "message": "音乐" }, + "app.settings.game-options.setting.narrator-hotkey.label": { "message": "旁白快捷键" }, + "app.settings.game-options.setting.narrator.description": { "message": "选择旁白朗读的内容。" }, + "app.settings.game-options.setting.narrator.label": { "message": "旁白" }, + "app.settings.game-options.setting.neutral-volume.label": { "message": "友好生物" }, + "app.settings.game-options.setting.notification-time.description": { "message": "提示通知保持显示的时长。" }, + "app.settings.game-options.setting.notification-time.label": { "message": "通知时长" }, + "app.settings.game-options.setting.operator-items-tab.label": { "message": "管理员物品栏标签" }, + "app.settings.game-options.setting.panorama-speed.label": { "message": "全景图速度" }, + "app.settings.game-options.setting.particles.label": { "message": "粒子" }, + "app.settings.game-options.setting.players-volume.label": { "message": "玩家" }, + "app.settings.game-options.setting.prioritize-chunk-updates.label": { "message": "区块更新优先级" }, + "app.settings.game-options.setting.quit-shortcuts.label": { "message": "退出快捷键" }, + "app.settings.game-options.setting.raw-mouse-input.label": { "message": "原始鼠标输入" }, + "app.settings.game-options.setting.realms-notifications.label": { "message": "Realms 通知" }, + "app.settings.game-options.setting.record-volume.label": { "message": "唱片机/音符盒" }, + "app.settings.game-options.setting.reduced-debug-info.label": { "message": "减少调试信息" }, + "app.settings.game-options.setting.render-distance.label": { "message": "渲染距离" }, + "app.settings.game-options.setting.right-pants-leg.label": { "message": "右裤腿" }, + "app.settings.game-options.setting.right-sleeve.label": { "message": "右袖" }, + "app.settings.game-options.setting.rotate-with-minecart.label": { "message": "随矿车旋转" }, + "app.settings.game-options.setting.save-chat-drafts.label": { "message": "保存聊天草稿" }, + "app.settings.game-options.setting.screen-effects.label": { "message": "屏幕效果" }, + "app.settings.game-options.setting.secure-chat-only.label": { "message": "仅显示安全聊天" }, + "app.settings.game-options.setting.sensitivity.label": { "message": "鼠标灵敏度" }, + "app.settings.game-options.setting.server-textures.label": { "message": "服务器纹理" }, + "app.settings.game-options.setting.share-presence.label": { "message": "共享在线状态" }, + "app.settings.game-options.setting.simulation-distance.description": { "message": "实体更新以及方块和流体更新的距离。" }, + "app.settings.game-options.setting.simulation-distance.label": { "message": "模拟距离" }, + "app.settings.game-options.setting.snooper.label": { "message": "数据收集" }, + "app.settings.game-options.setting.sprint-window.label": { "message": "疾跑窗口" }, + "app.settings.game-options.setting.subtitles.description": { "message": "显示游戏声音的字幕。" }, + "app.settings.game-options.setting.subtitles.label": { "message": "字幕" }, + "app.settings.game-options.setting.text-background-opacity.label": { "message": "文字背景不透明度" }, + "app.settings.game-options.setting.texture-filtering.label": { "message": "纹理过滤" }, + "app.settings.game-options.setting.toggle-attack.label": { "message": "切换攻击" }, + "app.settings.game-options.setting.toggle-crouch.description": { "message": "按一次即可保持潜行。" }, + "app.settings.game-options.setting.toggle-crouch.label": { "message": "切换潜行" }, + "app.settings.game-options.setting.toggle-sprint.description": { "message": "按一次即可保持疾跑。" }, + "app.settings.game-options.setting.toggle-sprint.label": { "message": "切换疾跑" }, + "app.settings.game-options.setting.toggle-use.label": { "message": "切换使用" }, + "app.settings.game-options.setting.touchscreen.label": { "message": "触屏模式" }, + "app.settings.game-options.setting.ui-volume.label": { "message": "界面" }, + "app.settings.game-options.setting.unfocused-chat-height.label": { "message": "未聚焦时聊天高度" }, + "app.settings.game-options.setting.use-vbo.label": { "message": "使用 VBO" }, + "app.settings.game-options.setting.view-bobbing.description": { "message": "行走时为镜头添加摇摆效果。" }, + "app.settings.game-options.setting.view-bobbing.label": { "message": "视角摇摆" }, + "app.settings.game-options.setting.vignette.label": { "message": "晕影" }, + "app.settings.game-options.setting.voice-volume.label": { "message": "语音和语音朗读" }, + "app.settings.game-options.setting.vsync.description": { "message": "将帧率限制为显示器刷新率,以防止画面撕裂。" }, + "app.settings.game-options.setting.vsync.label": { "message": "垂直同步" }, + "app.settings.game-options.setting.weather-radius.label": { "message": "天气范围" }, + "app.settings.game-options.setting.weather-volume.label": { "message": "天气" }, + "app.settings.game-options.validation.changed-since-opened": { "message": "此设置已在其他位置更改,请检查后重试。" }, + "app.settings.game-options.validation.invalid-value": { "message": "请选择有效值。" }, + "app.settings.game-options.validation.missing-value": { "message": "请先选择一个值。" }, + "app.settings.game-options.validation.no-compatible-instances": { "message": "部分实例无法使用此设置" }, + "app.settings.synced-options.game-settings.conflict-text": { "message": "我们已加载最新设置。请检查更改后再次保存。" }, + "app.settings.synced-options.game-settings.conflict-title": { "message": "这些设置已在其他地方更改" }, + "app.settings.synced-options.game-settings.custom-empty": { "message": "Modrinth 在某个实例中找到模组添加的设置后,它们会显示在这里。" }, + "app.settings.synced-options.game-settings.disable-all": { "message": "取消同步全部" }, + "app.settings.synced-options.game-settings.empty": { "message": "没有匹配当前搜索或筛选条件的设置。" }, + "app.settings.synced-options.game-settings.enable-all": { "message": "同步全部" }, + "app.settings.synced-options.game-settings.load-failed": { "message": "无法加载游戏设置。" }, + "app.settings.synced-options.game-settings.loading": { "message": "正在加载设置……" }, + "app.settings.synced-options.game-settings.not-being-synced": { "message": "未同步" }, + "app.settings.synced-options.game-settings.retry": { "message": "重试" }, + "app.settings.synced-options.game-settings.save": { "message": "保存设置" }, + "app.settings.synced-options.game-settings.search": { "message": "搜索设置……" }, + "app.settings.synced-options.game-settings.sync-description": { "message": "同步的选项会应用到每个实例,并覆盖整合包中包含的值。" }, + "app.settings.synced-options.game-settings.sync-setting": { "message": "在所有实例间同步{setting}" }, + "app.settings.synced-options.game-settings.sync-title": { "message": "同步游戏设置" }, + "app.settings.synced-options.game-settings.synced-across-instances": { "message": "已在所有实例间同步" }, + "app.settings.synced-options.game-settings.title": { "message": "游戏设置" }, + "app.settings.synced-options.game-settings.unsaved-changes": { "message": "你有未保存的更改。" }, + "app.settings.synced-options.game-settings.unsync-setting": { "message": "停止在所有实例间同步{setting}" }, + "app.settings.synced-options.game-settings.value-mixed": { "message": "各实例不同" }, + "app.settings.synced-options.game-settings.value-off": { "message": "关闭" }, + "app.settings.synced-options.game-settings.value-on": { "message": "开启" }, + "app.settings.synced-options.game-settings.value-unset": { "message": "选择一个值" } } diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/instance-settings-context.ts b/apps/app-frontend/src/pages/instance/components/settings-modal/instance-settings-context.ts new file mode 100644 index 0000000..87718a2 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/components/settings-modal/instance-settings-context.ts @@ -0,0 +1,2 @@ +export type { InstanceSettingsContext } from '@/providers/instance-settings' +export { injectInstanceSettings, provideInstanceSettings } from '@/providers/instance-settings' diff --git a/apps/app-frontend/src/providers/app-settings-modal.ts b/apps/app-frontend/src/providers/app-settings-modal.ts new file mode 100644 index 0000000..6f34988 --- /dev/null +++ b/apps/app-frontend/src/providers/app-settings-modal.ts @@ -0,0 +1,18 @@ +import type { InjectionKey } from 'vue' + +export type UnsavedChangesController = { + hasChanges: () => boolean + getOriginal: () => Record + getModified: () => Record + isSaving: () => boolean + reset: () => void + save: () => void | Promise +} + +export type AppSettingsModalContext = { + close: () => boolean + registerUnsavedChangesController: (controller: UnsavedChangesController | null) => void +} + +export const appSettingsModalContextKey: InjectionKey = + Symbol('appSettingsModalContext') diff --git a/apps/app-frontend/src/providers/instance-settings.ts b/apps/app-frontend/src/providers/instance-settings.ts index d6aa632..543fd81 100644 --- a/apps/app-frontend/src/providers/instance-settings.ts +++ b/apps/app-frontend/src/providers/instance-settings.ts @@ -8,7 +8,7 @@ export interface InstanceSettingsContext { offline?: boolean isMinecraftServer: Ref onUnlinked: () => void - closeModal?: () => void + closeModal?: (onAfterClose?: () => void) => void } export const [injectInstanceSettings, provideInstanceSettings] = diff --git a/apps/app/build.rs b/apps/app/build.rs index a418b53..c37692b 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -538,6 +538,33 @@ fn main() { "instance_export_screenshots", "instance_move_screenshots", "instance_open_screenshot", + "instance_get_synced_options", + "instance_get_initialized_synced_options", + "instance_get_synced_options_overview", + "instance_get_synced_option_capabilities", + "instance_get_synced_option_join_preview", + "instance_set_synced_option", + "instance_set_instance_synced_option", + "instance_get_synced_command_history", + "instance_set_synced_command_history", + "instance_list_synced_servers", + "instance_update_synced_server", + "instance_remove_synced_server", + "instance_get_synced_game_options_config", + "instance_preview_synced_game_option_changes", + "instance_save_synced_game_option_changes", + "instance_list_game_options_sync_sources", + "instance_get_game_setting_locale_labels", + "instance_get_local_game_options_config", + "instance_preview_local_game_option_changes", + "instance_save_local_game_option_changes", + "instance_get_pack_sync_preview", + "instance_sync_pack", + "instance_desync_pack", + "instance_list_synced_packs", + "instance_upload_synced_pack", + "instance_set_synced_pack_enabled", + "instance_remove_synced_pack", ]) .default_permission( DefaultPermissionRule::AllowAllCommands, diff --git a/apps/app/src/api/instance/mod.rs b/apps/app/src/api/instance/mod.rs index 28b31a6..83e4f38 100644 --- a/apps/app/src/api/instance/mod.rs +++ b/apps/app/src/api/instance/mod.rs @@ -135,6 +135,33 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_export_screenshots, instance_move_screenshots, instance_open_screenshot, + instance_get_synced_options, + instance_get_initialized_synced_options, + instance_get_synced_options_overview, + instance_get_synced_option_capabilities, + instance_get_synced_option_join_preview, + instance_set_synced_option, + instance_set_instance_synced_option, + instance_get_synced_command_history, + instance_set_synced_command_history, + instance_list_synced_servers, + instance_update_synced_server, + instance_remove_synced_server, + instance_get_synced_game_options_config, + instance_preview_synced_game_option_changes, + instance_save_synced_game_option_changes, + instance_list_game_options_sync_sources, + instance_get_game_setting_locale_labels, + instance_get_local_game_options_config, + instance_preview_local_game_option_changes, + instance_save_local_game_option_changes, + instance_get_pack_sync_preview, + instance_sync_pack, + instance_desync_pack, + instance_list_synced_packs, + instance_upload_synced_pack, + instance_set_synced_pack_enabled, + instance_remove_synced_pack, ]) .build() } @@ -178,6 +205,7 @@ pub struct Instance { pub linked_version_id: Option, pub linked_version_json_path: Option, pub linked_game_dir_mode: Option, + pub synced_options: theseus::instance::InstanceSyncedOptions, } #[derive(Deserialize, Debug, Clone)] @@ -362,6 +390,7 @@ impl From for Instance { .instance .linked_version_json_path, linked_game_dir_mode: metadata.instance.linked_game_dir_mode, + synced_options: metadata.synced_options, } } } @@ -1833,3 +1862,242 @@ pub async fn instance_get_backup_delete_summary( ) -> Result { Ok(theseus::instance::get_backup_delete_summary(instance_id).await?) } + +#[tauri::command] +pub async fn instance_get_synced_options() +-> Result { + Ok(theseus::instance::get_global_synced_options().await?) +} + +#[tauri::command] +pub async fn instance_get_initialized_synced_options() +-> Result { + Ok(theseus::instance::get_initialized_synced_options().await?) +} + +#[tauri::command] +pub async fn instance_get_synced_game_options_config() +-> Result { + Ok(theseus::instance::get_synced_game_options_config().await?) +} + +#[tauri::command] +pub async fn instance_preview_synced_game_option_changes( + request: theseus::instance::UpdateGameSettingsRequest, +) -> Result { + Ok(theseus::instance::preview_synced_game_option_changes(request).await?) +} + +#[tauri::command] +pub async fn instance_save_synced_game_option_changes( + request: theseus::instance::UpdateGameSettingsRequest, +) -> Result { + Ok(theseus::instance::save_synced_game_option_changes(request).await?) +} + +#[tauri::command] +pub async fn instance_list_game_options_sync_sources() +-> Result> { + Ok(theseus::instance::list_game_options_sync_sources().await?) +} + +#[tauri::command] +pub async fn instance_get_game_setting_locale_labels( + instance_id: Option, + locale: String, + option_ids: Vec, + refresh_sources: bool, +) -> Result { + Ok(theseus::instance::get_game_setting_locale_labels( + instance_id.as_deref(), + &locale, + option_ids, + refresh_sources, + ) + .await?) +} + +#[tauri::command] +pub async fn instance_get_local_game_options_config( + instance_id: String, +) -> Result { + Ok(theseus::instance::get_local_game_options_config(&instance_id).await?) +} + +#[tauri::command] +pub async fn instance_preview_local_game_option_changes( + instance_id: String, + request: theseus::instance::UpdateGameSettingsRequest, +) -> Result { + Ok(theseus::instance::preview_local_game_option_changes( + &instance_id, + request, + ) + .await?) +} + +#[tauri::command] +pub async fn instance_save_local_game_option_changes( + instance_id: String, + request: theseus::instance::UpdateGameSettingsRequest, +) -> Result { + Ok( + theseus::instance::save_local_game_option_changes( + &instance_id, + request, + ) + .await?, + ) +} + +#[tauri::command] +pub async fn instance_get_pack_sync_preview( + instance_id: String, + project_path: String, +) -> Result { + Ok( + theseus::instance::get_pack_sync_preview(&instance_id, &project_path) + .await?, + ) +} + +#[tauri::command] +pub async fn instance_sync_pack( + instance_id: String, + project_path: String, +) -> Result<()> { + Ok(theseus::instance::sync_pack(&instance_id, &project_path).await?) +} + +#[tauri::command] +pub async fn instance_desync_pack( + instance_id: String, + pack_id: String, + mode: theseus::instance::DesyncServerMode, +) -> Result<()> { + Ok(theseus::instance::desync_pack(&instance_id, &pack_id, mode).await?) +} + +#[tauri::command] +pub async fn instance_list_synced_packs( + project_type: theseus::data::ProjectType, +) -> Result> { + Ok(theseus::instance::list_synced_packs(project_type).await?) +} + +#[tauri::command] +pub async fn instance_upload_synced_pack( + path: PathBuf, + project_type: theseus::data::ProjectType, + game_versions: Vec, +) -> Result<()> { + Ok( + theseus::instance::upload_synced_pack( + path, + project_type, + game_versions, + ) + .await?, + ) +} + +#[tauri::command] +pub async fn instance_set_synced_pack_enabled( + pack_id: String, + enabled: bool, +) -> Result<()> { + Ok(theseus::instance::set_synced_pack_enabled(&pack_id, enabled).await?) +} + +#[tauri::command] +pub async fn instance_remove_synced_pack(pack_id: String) -> Result<()> { + Ok(theseus::instance::remove_synced_pack(&pack_id).await?) +} + +#[tauri::command] +pub async fn instance_get_synced_options_overview( + instance_id: String, +) -> Result { + Ok(theseus::instance::get_synced_options_overview(&instance_id).await?) +} + +#[tauri::command] +pub async fn instance_get_synced_option_capabilities( + instance_id: String, +) -> Result> { + Ok(theseus::instance::get_synced_option_capabilities(&instance_id).await?) +} + +#[tauri::command] +pub async fn instance_get_synced_option_join_preview( + instance_id: String, + option: theseus::instance::SyncedOption, +) -> Result { + Ok( + theseus::instance::get_synced_option_join_preview(&instance_id, option) + .await?, + ) +} + +#[tauri::command] +pub async fn instance_set_synced_option( + option: theseus::instance::SyncedOption, + enabled: bool, + base_instance_id: Option, +) -> Result { + Ok(theseus::instance::set_global_synced_option( + option, + enabled, + base_instance_id.as_deref(), + ) + .await?) +} + +#[tauri::command] +pub async fn instance_set_instance_synced_option( + instance_id: String, + option: theseus::instance::SyncedOption, + enabled: bool, + resolution: Option, +) -> Result { + let metadata = theseus::instance::set_instance_synced_option( + &instance_id, + option, + enabled, + resolution, + ) + .await?; + instance_from_metadata(metadata).await +} + +#[tauri::command] +pub async fn instance_get_synced_command_history() -> Result { + Ok(theseus::instance::get_synced_command_history().await?) +} + +#[tauri::command] +pub async fn instance_set_synced_command_history( + contents: String, +) -> Result { + Ok(theseus::instance::set_synced_command_history(&contents).await?) +} + +#[tauri::command] +pub async fn instance_list_synced_servers() +-> Result> { + Ok(theseus::instance::list_synced_servers().await?) +} + +#[tauri::command] +pub async fn instance_update_synced_server( + server: theseus::instance::SyncedServer, +) -> Result<()> { + theseus::instance::update_synced_server(server).await?; + Ok(()) +} + +#[tauri::command] +pub async fn instance_remove_synced_server(id: String) -> Result<()> { + theseus::instance::remove_synced_server(&id).await?; + Ok(()) +} 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/api/instance/mod.rs b/packages/app-lib/src/api/instance/mod.rs index 8f5117c..7779ae6 100644 --- a/packages/app-lib/src/api/instance/mod.rs +++ b/packages/app-lib/src/api/instance/mod.rs @@ -15,7 +15,16 @@ mod projects; mod run; mod screenshot_groups; mod screenshots; +pub(crate) mod synced_options; mod upgrade; +pub(crate) mod synced_packs { + pub(crate) use super::synced_packs_axolotl::{ + capture_resource_pack_selection_change, detach, + prepare_instance_update, reconcile, seed_from_instance, + }; +} +mod synced_packs_axolotl; +pub(crate) mod synced_servers; pub use self::backup::{ BackupDeleteSummary, BackupExclusion, BackupExclusionKind, BackupOperation, @@ -114,6 +123,48 @@ 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, +}; +pub use self::synced_options::{ + GlobalSyncedOptions, SyncedOptionCapability, SyncedOptionJoinAction, + SyncedOptionJoinPreview, SyncedOptionJoinResolution, SyncedOptionsOverview, + get_capabilities as get_synced_option_capabilities, + get_command_history as get_synced_command_history, + get_global_options as get_global_synced_options, + get_initialized_options as get_initialized_synced_options, + get_instance_option_join_preview as get_synced_option_join_preview, + get_overview as get_synced_options_overview, + set_command_history as set_synced_command_history, + set_global_option as set_global_synced_option, + set_instance_option as set_instance_synced_option, +}; +pub(crate) use self::synced_options::{ + reconcile_changed_file as reconcile_synced_option_file, + remove_generated_instance_files, +}; +pub use self::synced_packs_axolotl::{ + PackSyncPreview, PackSyncTarget, desync_pack, get_pack_sync_preview, + list_synced_packs, remove_synced_pack, set_synced_pack_enabled, sync_pack, + upload_synced_pack, +}; +pub use self::synced_servers::{ + DesyncServerMode, ServerSource, SyncedServer, desync_server, + list_synced_servers, remove_synced_server, update_synced_server, +}; pub use self::upgrade::{ dismiss_instance_post_upgrade_notice, execute_instance_upgrade, get_instance_post_upgrade_notice, get_instance_upgrade_plan, @@ -122,3 +173,4 @@ pub use self::upgrade::{ update_instance_upgrade_resolution, update_instance_upgrade_resolutions, }; pub use crate::state::{DailyPlaytime, DailyPlaytimeEntry}; +pub use crate::state::{InstanceSyncedOptions, SyncedOption}; diff --git a/packages/app-lib/src/api/instance/synced_options/command_history.rs b/packages/app-lib/src/api/instance/synced_options/command_history.rs new file mode 100644 index 0000000..19da764 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/command_history.rs @@ -0,0 +1,186 @@ +use super::COMMAND_HISTORY_FILE; +use super::files::{ + CheckpointStatus, begin_checkpoint, checkpoint, ensure_link, + finish_checkpoint, instance_game_dir, sha1_bytes, sha1_file, + synced_options_path, +}; +use super::orchestration::{create_synced_directories, option_effective}; +use crate::State; +use crate::state::{InstanceMetadata, SyncedOption}; +use crate::util::io; +use std::path::PathBuf; + +const COMMAND_HISTORY_LIMIT: usize = 50; + +pub async fn get_command_history() -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let path = command_history_path(&state); + if !path.exists() { + return Ok(String::new()); + } + Ok(String::from_utf8_lossy(&io::read(path).await?).into_owned()) +} + +pub async fn set_command_history(contents: &str) -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + create_synced_directories(&state).await?; + let normalized = normalize_command_history(contents); + io::write(command_history_path(&state), normalized.as_bytes()).await?; + refresh_command_history_links(&state).await?; + Ok(normalized) +} + +pub(super) async fn ensure_command_history( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + create_synced_directories(state).await?; + let canonical = command_history_path(state); + if !canonical.exists() { + let local = + instance_game_dir(metadata, state)?.join(COMMAND_HISTORY_FILE); + let contents = if local.exists() { + String::from_utf8_lossy(&io::read(&local).await?).into_owned() + } else { + String::new() + }; + io::write(&canonical, normalize_command_history(&contents)).await?; + } + let target = instance_game_dir(metadata, state)?.join(COMMAND_HISTORY_FILE); + let canonical_bytes = io::read(&canonical).await?; + let expected = sha1_bytes(&canonical_bytes); + begin_checkpoint( + &metadata.instance.id, + SyncedOption::CommandHistory, + "default", + &expected, + None, + 0, + state, + ) + .await?; + let mode = ensure_link(&canonical, &target).await?; + finish_checkpoint( + &metadata.instance.id, + SyncedOption::CommandHistory, + "default", + mode, + state, + ) + .await +} + +pub(super) async fn reconcile_command_history( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if !option_effective(metadata, SyncedOption::CommandHistory, state).await? { + return Ok(()); + } + let local = instance_game_dir(metadata, state)?.join(COMMAND_HISTORY_FILE); + if !local.exists() { + return ensure_command_history(metadata, state).await; + } + let symlink = tokio::fs::symlink_metadata(&local) + .await + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false); + let current_checkpoint = checkpoint( + &metadata.instance.id, + SyncedOption::CommandHistory, + "default", + state, + ) + .await?; + if current_checkpoint + .as_ref() + .is_some_and(|value| value.status == CheckpointStatus::Pending) + { + return ensure_command_history(metadata, state).await; + } + let actual = sha1_file(&local).await?; + let expected = current_checkpoint.map(|value| value.expected_sha1); + if !symlink && expected.as_deref() != Some(actual.as_str()) { + let contents = + String::from_utf8_lossy(&io::read(&local).await?).into_owned(); + io::write( + command_history_path(state), + normalize_command_history(&contents), + ) + .await?; + refresh_command_history_links(state).await?; + } else { + ensure_command_history(metadata, state).await?; + } + Ok(()) +} + +async fn refresh_command_history_links(state: &State) -> crate::Result<()> { + let instances = crate::state::list_instances(&state.pool).await?; + for metadata in instances { + if option_effective(&metadata, SyncedOption::CommandHistory, state) + .await? + { + ensure_command_history(&metadata, state).await?; + } + } + Ok(()) +} + +pub(super) fn normalize_command_history(contents: &str) -> String { + let lines = contents.lines().collect::>(); + let start = lines.len().saturating_sub(COMMAND_HISTORY_LIMIT); + let mut normalized = lines[start..].join("\n"); + if !normalized.is_empty() { + normalized.push('\n'); + } + normalized +} + +pub(super) async fn merge_command_history_from_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let canonical_path = command_history_path(state); + let canonical = if canonical_path.exists() { + String::from_utf8_lossy(&io::read(&canonical_path).await?).into_owned() + } else { + String::new() + }; + let local_path = + instance_game_dir(metadata, state)?.join(COMMAND_HISTORY_FILE); + let local = if local_path.exists() { + String::from_utf8_lossy(&io::read(&local_path).await?).into_owned() + } else { + String::new() + }; + let canonical_lines = canonical.lines().collect::>(); + let available = COMMAND_HISTORY_LIMIT.saturating_sub(canonical_lines.len()); + let mut seen = canonical_lines + .iter() + .copied() + .collect::>(); + let mut imported = Vec::new(); + for line in local.lines().rev() { + if imported.len() == available { + break; + } + if seen.insert(line) { + imported.push(line); + } + } + imported.reverse(); + imported.extend(canonical_lines); + let merged = normalize_command_history(&imported.join("\n")); + if merged != normalize_command_history(&canonical) { + io::write(&canonical_path, merged).await?; + refresh_command_history_links(state).await?; + } + Ok(()) +} + +pub(super) fn command_history_path(state: &State) -> PathBuf { + synced_options_path(state).join(COMMAND_HISTORY_FILE) +} diff --git a/packages/app-lib/src/api/instance/synced_options/files.rs b/packages/app-lib/src/api/instance/synced_options/files.rs new file mode 100644 index 0000000..db6222e --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/files.rs @@ -0,0 +1,338 @@ +use crate::state::{InstanceInstallStage, InstanceMetadata, SyncedOption}; +use crate::util::io; +use crate::{ErrorKind, State}; +use quartz_nbt::NbtCompound; +use sha1_smol::Sha1; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +pub(in crate::api::instance) struct SyncCheckpoint { + pub expected_sha1: String, + pub merge_base: Option>, + pub source_revision: i64, + pub status: CheckpointStatus, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::api::instance) enum CheckpointStatus { + Pending, + Ready, +} + +impl CheckpointStatus { + fn from_str(value: &str) -> Option { + match value { + "pending" => Some(Self::Pending), + "ready" => Some(Self::Ready), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(in crate::api::instance) enum LinkMode { + Symbolic, + #[cfg(windows)] + Hard, + #[cfg(windows)] + Copy, +} + +impl LinkMode { + fn as_str(self) -> &'static str { + match self { + Self::Symbolic => "symbolic", + #[cfg(windows)] + Self::Hard => "hard", + #[cfg(windows)] + Self::Copy => "copy", + } + } +} + +pub(in crate::api::instance) fn safe_instance_id(instance_id: &str) -> String { + instance_id.replace([':', '/', '\\'], "_") +} + +pub(crate) fn instance_game_dir( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + ) +} + +pub(crate) fn synced_options_path(state: &State) -> PathBuf { + state.directories.synced_options_dir() +} + +pub(in crate::api::instance) fn sync_files_are_protected( + metadata: &InstanceMetadata, +) -> bool { + matches!( + metadata.instance.install_stage, + InstanceInstallStage::MinecraftInstalling + | InstanceInstallStage::PackInstalling + ) +} + +pub(in crate::api::instance) async fn instance_is_running( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + crate::state::instance_has_running_process(&metadata.instance.id, state) + .await +} + +pub(in crate::api::instance) fn option_can_apply_while_running( + option: SyncedOption, +) -> bool { + matches!( + option, + SyncedOption::GameOptions + | SyncedOption::Screenshots + | SyncedOption::ResourcePacks + ) +} + +pub(in crate::api::instance) fn instance_option_enabled( + metadata: &InstanceMetadata, + option: SyncedOption, +) -> bool { + match option { + SyncedOption::GameOptions => metadata.synced_options.game_options, + SyncedOption::CommandHistory => metadata.synced_options.command_history, + SyncedOption::MultiplayerServers => { + metadata.synced_options.multiplayer_servers + } + SyncedOption::CreativeHotbars => { + metadata.synced_options.creative_hotbars + } + SyncedOption::Screenshots => metadata.synced_options.screenshots, + SyncedOption::ResourcePacks => metadata.synced_options.resource_packs, + SyncedOption::DataPacks => metadata.synced_options.data_packs, + } +} + +pub(in crate::api::instance) async fn ensure_link( + source: &Path, + target: &Path, +) -> crate::Result { + if let Some(parent) = target.parent() { + io::create_dir_all(parent).await?; + } + if tokio::fs::symlink_metadata(target) + .await + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + && tokio::fs::read_link(target) + .await + .is_ok_and(|current| current == source) + { + return Ok(LinkMode::Symbolic); + } + if tokio::fs::symlink_metadata(target).await.is_ok() { + io::remove_file(target).await?; + } + + #[cfg(unix)] + { + tokio::fs::symlink(source, target).await?; + Ok(LinkMode::Symbolic) + } + #[cfg(windows)] + { + if tokio::fs::symlink_file(source, target).await.is_ok() { + return Ok(LinkMode::Symbolic); + } + if tokio::fs::hard_link(source, target).await.is_ok() { + return Ok(LinkMode::Hard); + } + io::copy(source, target).await?; + Ok(LinkMode::Copy) + } +} + +pub(in crate::api::instance) async fn detach_link( + source: &Path, + target: &Path, +) -> crate::Result<()> { + let target_metadata = tokio::fs::symlink_metadata(target).await.ok(); + let contents = if target_metadata.is_some() && target.exists() { + Some(io::read(target).await?) + } else if source.exists() { + Some(io::read(source).await?) + } else { + None + }; + if target_metadata.is_some() { + io::remove_file(target).await?; + } + if let Some(contents) = contents { + io::write(target, contents).await?; + } + Ok(()) +} + +pub(in crate::api::instance) async fn begin_checkpoint( + instance_id: &str, + option: SyncedOption, + variant: &str, + expected_sha1: &str, + merge_base: Option<&[u8]>, + source_revision: i64, + state: &State, +) -> crate::Result<()> { + let option_name = option.as_str(); + sqlx::query( + " + INSERT INTO instance_sync_checkpoints + (instance_id, feature, variant, expected_sha1, merge_base, + source_revision, status, link_mode) + VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL) + ON CONFLICT(instance_id, feature, variant) DO UPDATE SET + expected_sha1 = excluded.expected_sha1, + merge_base = excluded.merge_base, + source_revision = excluded.source_revision, + status = 'pending', + link_mode = NULL + ", + ) + .bind(instance_id) + .bind(option_name) + .bind(variant) + .bind(expected_sha1) + .bind(merge_base) + .bind(source_revision) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub(in crate::api::instance) async fn finish_checkpoint( + instance_id: &str, + option: SyncedOption, + variant: &str, + mode: LinkMode, + state: &State, +) -> crate::Result<()> { + let option_name = option.as_str(); + let link_mode = mode.as_str(); + sqlx::query( + " + UPDATE instance_sync_checkpoints + SET status = 'ready', link_mode = ? + WHERE instance_id = ? AND feature = ? AND variant = ? + ", + ) + .bind(link_mode) + .bind(instance_id) + .bind(option_name) + .bind(variant) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub(in crate::api::instance) async fn finish_plain_checkpoint( + instance_id: &str, + option: SyncedOption, + variant: &str, + state: &State, +) -> crate::Result<()> { + let option_name = option.as_str(); + sqlx::query( + " + UPDATE instance_sync_checkpoints + SET status = 'ready', link_mode = NULL + WHERE instance_id = ? AND feature = ? AND variant = ? + ", + ) + .bind(instance_id) + .bind(option_name) + .bind(variant) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub(in crate::api::instance) async fn checkpoint( + instance_id: &str, + option: SyncedOption, + variant: &str, + state: &State, +) -> crate::Result> { + let option_name = option.as_str(); + let row = sqlx::query( + r#" + SELECT expected_sha1, merge_base, source_revision, status + FROM instance_sync_checkpoints + WHERE instance_id = ? AND feature = ? AND variant = ? + "#, + ) + .bind(instance_id) + .bind(option_name) + .bind(variant) + .fetch_optional(&state.pool) + .await?; + use sqlx::Row; + row.map(|row| { + let status: String = row.get("status"); + Ok(SyncCheckpoint { + expected_sha1: row.get("expected_sha1"), + merge_base: row.get("merge_base"), + source_revision: row.get("source_revision"), + status: CheckpointStatus::from_str(&status).ok_or_else(|| { + ErrorKind::InputError(format!( + "Unknown sync checkpoint status {status}" + )) + })?, + }) + }) + .transpose() +} + +pub(in crate::api::instance) async fn sha1_file( + path: &Path, +) -> crate::Result { + Ok(Sha1::from(io::read(path).await?).digest().to_string()) +} + +pub(in crate::api::instance) fn sha1_bytes(bytes: &[u8]) -> String { + Sha1::from(bytes).digest().to_string() +} + +pub(in crate::api::instance) async fn read_nbt_file( + path: &Path, +) -> crate::Result { + let bytes = io::read(path).await?; + let (root, _) = quartz_nbt::io::read_nbt( + &mut Cursor::new(bytes), + quartz_nbt::io::Flavor::Uncompressed, + )?; + Ok(root) +} + +pub(in crate::api::instance) fn nbt_to_bytes( + root: &NbtCompound, +) -> crate::Result> { + let mut bytes = Vec::new(); + quartz_nbt::io::write_nbt( + &mut bytes, + None, + root, + quartz_nbt::io::Flavor::Uncompressed, + )?; + Ok(bytes) +} + +pub(in crate::api::instance) fn nbt_from_bytes( + bytes: Vec, +) -> crate::Result { + let (root, _) = quartz_nbt::io::read_nbt( + &mut Cursor::new(bytes), + quartz_nbt::io::Flavor::Uncompressed, + )?; + Ok(root) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/api_types.rs b/packages/app-lib/src/api/instance/synced_options/game_options/api_types.rs new file mode 100644 index 0000000..f397e21 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/api_types.rs @@ -0,0 +1,203 @@ +use crate::state::{CanonicalValue, GameOptionKind}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionsSourceIssue { + InstallingOrUpdating, + UnsupportedVersion, + MissingOptionsFile, + NoSyncableSettings, + UnreadableOptionsFile, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionsSourceCandidate { + pub source_id: String, + pub instance_id: String, + pub name: String, + pub icon_path: Option, + pub game_version: Option, + pub eligible: bool, + pub disabled_reason: Option, + pub recognized_setting_count: u32, + pub custom_setting_count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameSettingCategory { + pub id: String, + pub is_custom: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionEditorDefinition { + #[serde(rename = "type")] + pub type_: String, + pub min: Option, + pub max: Option, + pub step: Option, + pub unit: Option, + pub choices: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionEditorChoice { + pub value: String, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionCompatibilityStatus { + Ready, + Deferred, + WaitingForFile, + WaitingForBase, + NotAvailable, + UnsupportedValue, + Unmappable, + CatalogUncovered, + Controlled, + Degraded, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionMappingKind { + Direct, + Legacy, + Migrated, + Lossless, + Lossy, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionCompatibilityReason { + LauncherControlled, + CatalogUncovered, + InspectionFailed, + WaitingForOptionsFile, + UnsupportedValue, + MigratesOnWrite, + WaitingForCompatibleBase, + MissingSetting, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionCompatibilityBucket { + pub instance_count: u32, + pub write_keys: Vec, + pub eventual_keys: Vec, + pub game_versions: Vec, + pub status: GameOptionCompatibilityStatus, + pub mapping: Option, + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameOptionCompatibility { + pub total_participating: u32, + pub will_receive: u32, + pub write_now: u32, + pub left_local: u32, + pub buckets: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionValueState { + Canonical, + UniformLocal, + Mixed, + Unset, + Invalid, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionValidationIssue { + MissingValue, + NoCompatibleInstances, + InvalidValue, + ChangedSinceOpened, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EditableGameSetting { + pub option_id: String, + pub category_id: String, + pub kind: GameOptionKind, + pub raw_key: Option, + pub sync_enabled: bool, + pub canonical_value: Option, + pub value_state: GameOptionValueState, + pub option_revision: u64, + pub editor: GameOptionEditorDefinition, + pub compatibility: GameOptionCompatibility, + pub validation_error: Option, + pub controlled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameSettingsEditorState { + pub summary_revision: String, + pub canonical_revision: u64, + pub catalog_revision: u32, + pub total_participating: u32, + pub categories: Vec, + pub settings: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UpdateGameSettingsRequest { + pub expected_summary_revision: String, + pub expected_canonical_revision: u64, + pub expected_catalog_revision: u32, + pub changes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GameSettingChange { + pub option_id: String, + pub base_option_revision: u64, + pub sync_enabled: Option, + #[serde( + default, + with = "serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub canonical_value: Option>, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct SaveGameSettingsResult { + pub state: Option, + pub applied: u32, + pub migrated: u32, + pub deferred: u32, + pub unsupported: u32, + pub failed: u32, + pub conflicts: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SyncOutcome { + Applied, + Migrated, + Unchanged, + Deferred, + WaitingForFile, +} + +pub(super) fn canonical_values_equal( + left: &CanonicalValue, + right: &CanonicalValue, +) -> bool { + match (left, right) { + (CanonicalValue::Decimal(left), CanonicalValue::Decimal(right)) => { + left.parse::().ok() == right.parse::().ok() + } + _ => left == right, + } +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/accessibility.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/accessibility.rs new file mode 100644 index 0000000..a4a9912 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/accessibility.rs @@ -0,0 +1,207 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, UNIT_INTERVAL, ValueEncoding, + ranged_setting, version_changes, versioned_setting, +}; + +const NARRATOR: &[&str] = &["0", "1", "2", "3"]; + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + ranged_setting( + "narrator", + &["narrator"], + "1.12", + "26.3", + "accessibility", + true, + SettingEditor::Enum(NARRATOR), + ValueEncoding::Enum(NARRATOR), + ), + ranged_setting( + "subtitles", + &["showSubtitles"], + "1.9", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "high_contrast", + &["highContrast"], + "1.19.4", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "dark_splash", + &["darkMojangStudiosBackground"], + "1.17", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "notification_time", + &["notificationDisplayTime"], + "1.19.4", + "26.3", + "accessibility", + true, + SettingEditor::Decimal { + min: 0.5, + max: 10.0, + step: 0.5, + unit: Some("multiplier"), + }, + ValueEncoding::Decimal, + ), + ranged_setting( + "fov_effects", + &["fovEffectScale"], + "1.16.2", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "screen_effects", + &["screenEffectScale"], + "1.16.2", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "darkness_pulsing", + &["darknessEffectScale"], + "1.19", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "damage_tilt", + &["damageTiltStrength"], + "1.19.4", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "glint_speed", + &["glintSpeed"], + "1.19.4", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "glint_strength", + &["glintStrength"], + "1.19.4", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "hide_lightning_flashes", + &["hideLightningFlashes"], + "1.18", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "hide_splash_texts", + &["hideSplashTexts"], + "1.20.3", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "high_contrast_outline", + &["highContrastBlockOutline"], + "1.21.2", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "narrator_hotkey", + &["narratorHotkey"], + "1.20.2", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "autosave_indicator", + &["showAutosaveIndicator"], + "1.18", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "panorama_speed", + &["panoramaScrollSpeed"], + "1.19.3", + "26.3", + "accessibility", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + versioned_setting( + "menu_background_blur", + &["menuBackgroundBlurriness"], + version_changes::MENU_BACKGROUND_BLUR_KEYS, + "accessibility", + true, + SettingEditor::Integer { + min: 0, + max: 10, + step: 1, + }, + ValueEncoding::MenuBackgroundBlur, + ), + ranged_setting( + "rotate_with_minecart", + &["rotateWithMinecart"], + "1.21.2", + "26.3", + "accessibility", + true, + BOOL, + ValueEncoding::Bool, + ), +]; diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/chat.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/chat.rs new file mode 100644 index 0000000..f8d161f --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/chat.rs @@ -0,0 +1,194 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, UNIT_INTERVAL, ValueEncoding, + ranged_setting, version_changes, versioned_setting, +}; + +const VISIBILITY: &[&str] = &["0", "1", "2"]; +const CHAT_PREVIEW: &[&str] = &["0", "1", "2"]; + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + ranged_setting( + "chat_visibility", + &["chatVisibility"], + "1.3.1", + "26.3", + "chat", + true, + SettingEditor::Enum(VISIBILITY), + ValueEncoding::Enum(VISIBILITY), + ), + ranged_setting( + "chat_colors", + &["chatColors"], + "1.3.1", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "chat_links", + &["chatLinks"], + "1.3.1", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "chat_links_prompt", + &["chatLinksPrompt"], + "1.3.1", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "chat_opacity", + &["chatOpacity"], + "1.3.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_scale", + &["chatScale"], + "1.5.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_width", + &["chatWidth"], + "1.5.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "focused_chat_height", + &["chatHeightFocused"], + "1.5.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "unfocused_chat_height", + &["chatHeightUnfocused"], + "1.5.1", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_line_spacing", + &["chatLineSpacing"], + "1.16", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_delay", + &["chatDelay"], + "1.16", + "26.3", + "chat", + true, + SettingEditor::Decimal { + min: 0.0, + max: 6.0, + step: 0.1, + unit: Some("seconds"), + }, + ValueEncoding::Decimal, + ), + ranged_setting( + "text_background_opacity", + &["textBackgroundOpacity"], + "1.14", + "26.3", + "chat", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "chat_background_only", + &["backgroundForChatOnly"], + "1.14", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "auto_suggestions", + &["autoSuggestions"], + "1.13", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "secure_chat_only", + &["onlyShowSecureChat"], + "1.19", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "save_chat_drafts", + &["saveChatDrafts"], + "1.21.9", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "hide_matched_names", + &["hideMatchedNames"], + "1.16.4", + "26.3", + "chat", + true, + BOOL, + ValueEncoding::Bool, + ), + versioned_setting( + "chat_preview", + &["chatPreview"], + version_changes::CHAT_PREVIEW_KEYS, + "chat", + true, + SettingEditor::Enum(CHAT_PREVIEW), + ValueEncoding::ChatPreview, + ), +]; diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/controls.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/controls.rs new file mode 100644 index 0000000..9e996c1 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/controls.rs @@ -0,0 +1,413 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, ValueEncoding, ranged_setting, + setting, version_changes, versioned_setting, +}; + +macro_rules! key_setting { + ($id:literal, $key:literal, $since:literal, $until:literal) => { + ranged_setting( + $id, + &[$key], + $since, + $until, + "controls", + true, + SettingEditor::KeyBinding, + ValueEncoding::KeyBinding, + ) + }; +} + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + setting( + "sensitivity", + &["mouseSensitivity"], + "controls", + true, + SettingEditor::Decimal { + min: 0.0, + max: 1.0, + step: 0.005, + unit: Some("percent"), + }, + ValueEncoding::Decimal, + ), + setting( + "invert_mouse", + &["invertYMouse"], + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "invert_horizontal_mouse", + &["invertXMouse"], + "1.21.9", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "auto_jump", + &["autoJump"], + "1.10", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "toggle_crouch", + &["toggleCrouch"], + "1.15", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "toggle_sprint", + &["toggleSprint"], + "1.15", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "toggle_attack", + &["toggleAttack"], + "1.21.9", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "toggle_use", + &["toggleUse"], + "1.21.9", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "discrete_mouse_scroll", + &["discrete_mouse_scroll"], + "1.14", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "mouse_wheel_sensitivity", + &["mouseWheelSensitivity"], + "1.13", + "26.3", + "controls", + true, + SettingEditor::UnboundedDecimal, + ValueEncoding::Decimal, + ), + ranged_setting( + "raw_mouse_input", + &["rawMouseInput"], + "1.14.4", + "26.2", + "controls", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "touchscreen", + &["touchscreen"], + "1.4.4", + "26.1.2", + "controls", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "allow_cursor_changes", + &["allowCursorChanges"], + "1.21.9", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "sprint_window", + &["sprintWindow"], + "1.21.9", + "26.3", + "controls", + true, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "operator_items_tab", + &["operatorItemsTab"], + "1.19.3", + "26.3", + "controls", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "ctrl_click_right_click", + &["ctrlClickEmulatesRightClick"], + "26.3", + "26.3", + "controls", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "quit_shortcuts", + &["quitShortcuts"], + "26.3", + "26.3", + "controls", + false, + BOOL, + ValueEncoding::Bool, + ), + key_setting!("key.forward", "key_key.forward", "1.0", "26.3"), + key_setting!("key.left", "key_key.left", "1.0", "26.3"), + key_setting!("key.back", "key_key.back", "1.0", "26.3"), + key_setting!("key.right", "key_key.right", "1.0", "26.3"), + key_setting!("key.jump", "key_key.jump", "1.0", "26.3"), + key_setting!("key.sneak", "key_key.sneak", "1.0", "26.3"), + key_setting!("key.sprint", "key_key.sprint", "1.7.2", "26.3"), + key_setting!("key.inventory", "key_key.inventory", "1.0", "26.3"), + versioned_setting( + "key.swap_offhand", + &["key_key.swapOffhand", "key_key.swapHands"], + version_changes::SWAP_OFFHAND_KEYS, + "controls", + true, + SettingEditor::KeyBinding, + ValueEncoding::KeyBinding, + ), + key_setting!("key.drop", "key_key.drop", "1.0", "26.3"), + key_setting!("key.use", "key_key.use", "1.0", "26.3"), + key_setting!("key.attack", "key_key.attack", "1.0", "26.3"), + key_setting!("key.pick_item", "key_key.pickItem", "1.0", "26.3"), + key_setting!("key.chat", "key_key.chat", "1.0", "26.3"), + key_setting!("key.player_list", "key_key.playerlist", "1.0", "26.3"), + key_setting!("key.command", "key_key.command", "1.3.1", "26.3"), + key_setting!("key.screenshot", "key_key.screenshot", "1.7.2", "26.3"), + key_setting!( + "key.perspective", + "key_key.togglePerspective", + "1.7.2", + "26.3" + ), + key_setting!("key.fullscreen", "key_key.fullscreen", "1.7.3", "26.3"), + key_setting!("key.advancements", "key_key.advancements", "1.12", "26.3"), + key_setting!("key.smooth_camera", "key_key.smoothCamera", "1.7.2", "26.3"), + key_setting!( + "key.spectator_outlines", + "key_key.spectatorOutlines", + "1.8", + "26.3" + ), + key_setting!( + "key.save_toolbar", + "key_key.saveToolbarActivator", + "1.12", + "26.3" + ), + key_setting!( + "key.load_toolbar", + "key_key.loadToolbarActivator", + "1.12", + "26.3" + ), + key_setting!( + "key.social_interactions", + "key_key.socialInteractions", + "1.16.4", + "26.3" + ), + key_setting!( + "key.quick_actions", + "key_key.quickActions", + "1.21.6", + "26.3" + ), + key_setting!( + "key.spectator_hotbar", + "key_key.spectatorHotbar", + "1.21.9", + "26.3" + ), + key_setting!("key.friends", "key_key.friends", "26.2", "26.3"), + key_setting!("key.toggle_gui", "key_key.toggleGui", "1.21.11", "26.3"), + key_setting!( + "key.toggle_spectator_shader", + "key_key.toggleSpectatorShaderEffects", + "1.21.11", + "26.3" + ), + key_setting!("key.hotbar.1", "key_key.hotbar.1", "1.7.2", "26.3"), + key_setting!("key.hotbar.2", "key_key.hotbar.2", "1.7.2", "26.3"), + key_setting!("key.hotbar.3", "key_key.hotbar.3", "1.7.2", "26.3"), + key_setting!("key.hotbar.4", "key_key.hotbar.4", "1.7.2", "26.3"), + key_setting!("key.hotbar.5", "key_key.hotbar.5", "1.7.2", "26.3"), + key_setting!("key.hotbar.6", "key_key.hotbar.6", "1.7.2", "26.3"), + key_setting!("key.hotbar.7", "key_key.hotbar.7", "1.7.2", "26.3"), + key_setting!("key.hotbar.8", "key_key.hotbar.8", "1.7.2", "26.3"), + key_setting!("key.hotbar.9", "key_key.hotbar.9", "1.7.2", "26.3"), + key_setting!( + "key.debug.overlay", + "key_key.debug.overlay", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.modifier", + "key_key.debug.modifier", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.reload_chunks", + "key_key.debug.reloadChunk", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.hitboxes", + "key_key.debug.showHitboxes", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.clear_chat", + "key_key.debug.clearChat", + "1.21.11", + "26.3" + ), + key_setting!("key.debug.crash", "key_key.debug.crash", "1.21.11", "26.3"), + key_setting!( + "key.debug.chunk_borders", + "key_key.debug.showChunkBorders", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.advanced_tooltips", + "key_key.debug.showAdvancedTooltips", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.copy_recreate_command", + "key_key.debug.copyRecreateCommand", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.spectate", + "key_key.debug.spectate", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.switch_game_mode", + "key_key.debug.switchGameMode", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.options", + "key_key.debug.debugOptions", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.focus_pause", + "key_key.debug.focusPause", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.dump_dynamic_textures", + "key_key.debug.dumpDynamicTextures", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.reload_resource_packs", + "key_key.debug.reloadResourcePacks", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.profiling", + "key_key.debug.profiling", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.copy_location", + "key_key.debug.copyLocation", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.dump_version", + "key_key.debug.dumpVersion", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.profiling_chart", + "key_key.debug.profilingChart", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.fps_charts", + "key_key.debug.fpsCharts", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.network_charts", + "key_key.debug.networkCharts", + "1.21.11", + "26.3" + ), + key_setting!( + "key.debug.lightmap_texture", + "key_key.debug.lightmapTexture", + "26.1", + "26.3" + ), + key_setting!( + "key.debug.improved_transparency", + "key_key.debug.improvedTransparency", + "26.3", + "26.3" + ), +]; diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/data_versions.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/data_versions.rs new file mode 100644 index 0000000..5e30337 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/data_versions.rs @@ -0,0 +1,127 @@ +use phf::phf_map; + +/// https://minecraft.wiki/w/Module:Protocol_version/Versions +/// Covers 1.10-pre1, which introduced the options version field, through +/// 18w47a, the last snapshot without embedded `version.json` metadata. +pub(in crate::api::instance) const LEGACY_DATA_VERSIONS: phf::Map<&str, u32> = phf_map! { + "18w47a" => 1912, + "18w46a" => 1910, + "18w45a" => 1908, + "18w44a" => 1907, + "18w43c" => 1903, + "18w43b" => 1902, + "18w43a" => 1901, + "1.13.2" => 1631, + "1.13.2-pre2" => 1630, + "1.13.2-pre1" => 1629, + "1.13.1" => 1628, + "1.13.1-pre2" => 1627, + "1.13.1-pre1" => 1626, + "18w33a" => 1625, + "18w32a" => 1623, + "18w31a" => 1622, + "18w30b" => 1621, + "18w30a" => 1620, + "1.13" => 1519, + "1.13-pre10" => 1518, + "1.13-pre9" => 1517, + "1.13-pre8" => 1516, + "1.13-pre7" => 1513, + "1.13-pre6" => 1512, + "1.13-pre5" => 1511, + "1.13-pre4" => 1504, + "1.13-pre3" => 1503, + "1.13-pre2" => 1502, + "1.13-pre1" => 1501, + "18w22c" => 1499, + "18w22b" => 1498, + "18w22a" => 1497, + "18w21b" => 1496, + "18w21a" => 1495, + "18w20c" => 1493, + "18w20b" => 1491, + "18w20a" => 1489, + "18w19b" => 1485, + "18w19a" => 1484, + "18w16a" => 1483, + "18w15a" => 1482, + "18w14b" => 1481, + "18w14a" => 1479, + "18w11a" => 1478, + "18w10d" => 1477, + "18w10c" => 1476, + "18w10b" => 1474, + "18w10a" => 1473, + "18w09a" => 1472, + "18w08b" => 1471, + "18w08a" => 1470, + "18w07c" => 1469, + "18w07b" => 1468, + "18w07a" => 1467, + "18w06a" => 1466, + "18w05a" => 1464, + "18w03b" => 1463, + "18w03a" => 1462, + "18w02a" => 1461, + "18w01a" => 1459, + "17w50a" => 1457, + "17w49b" => 1455, + "17w49a" => 1454, + "17w48a" => 1453, + "17w47b" => 1452, + "17w47a" => 1451, + "17w46a" => 1449, + "17w45b" => 1448, + "17w45a" => 1447, + "17w43b" => 1445, + "17w43a" => 1444, + "1.12.2" => 1343, + "1.12.2-pre2" => 1342, + "1.12.2-pre1" => 1341, + "1.12.1" => 1241, + "1.12.1-pre1" => 1240, + "17w31a" => 1239, + "1.12" => 1139, + "1.12-pre7" => 1138, + "1.12-pre6" => 1137, + "1.12-pre5" => 1136, + "1.12-pre4" => 1135, + "1.12-pre3" => 1134, + "1.12-pre2" => 1133, + "1.12-pre1" => 1132, + "17w18b" => 1131, + "17w18a" => 1130, + "17w17b" => 1129, + "17w17a" => 1128, + "17w16b" => 1127, + "17w16a" => 1126, + "17w15a" => 1125, + "17w14a" => 1124, + "17w13b" => 1123, + "17w13a" => 1122, + "17w06a" => 1022, + "1.11.2" => 922, + "1.11.1" => 921, + "16w50a" => 920, + "1.11" => 819, + "1.11-pre1" => 818, + "16w44a" => 817, + "16w43a" => 816, + "16w42a" => 815, + "16w41a" => 814, + "16w40a" => 813, + "16w39c" => 812, + "16w39b" => 811, + "16w39a" => 809, + "16w38a" => 807, + "16w36a" => 805, + "16w35a" => 803, + "16w33a" => 802, + "16w32b" => 801, + "16w32a" => 800, + "1.10.2" => 512, + "1.10.1" => 511, + "1.10" => 510, + "1.10-pre2" => 507, + "1.10-pre1" => 506, +}; diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/known_vanilla_keys.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/known_vanilla_keys.rs new file mode 100644 index 0000000..ece5c71 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/known_vanilla_keys.rs @@ -0,0 +1,255 @@ +// Never treat these Minecraft keys as settings added by mods. NEVER_SYNC_KEYS are +// not safe or useful to share; KNOWN_VANILLA_KEYS covers the vanilla catalog, +// including entries that have a dedicated editor. +pub(super) const NEVER_SYNC_KEYS: &[&str] = &[ + "version", + "resourcePacks", + "incompatibleResourcePacks", + "lastServer", + "startedCleanly", + "tutorialStep", + "joinedFirstServer", + "onboardAccessibility", + "soundDevice", + "fullscreenResolution", + "overrideWidth", + "overrideHeight", + "difficulty", + "fboEnable", + "saturation", + "streamBytesPerPixel", + "streamMicVolume", + "streamGameVolume", + "streamKbps", + "streamFps", + "streamCompression", + "streamSendMetadata", + "streamPreferredServer", + "streamChatEnabled", + "streamChatUserFilter", + "streamMicToggleBehavior", + "skin", + "key_key.streamCommercial", + "key_key.streamPauseUnpause", + "key_key.streamStartStop", + "key_key.streamToggleMic", + "streamSystemVolume", + "showInventoryAchievementHint", + "enableWeakAttacks", + "skipRealms32bitWarning", + "key_Build", + "key_Load location", + "key_Save location", + "showFrameRate", + "limitFramerate", + "key_key.fog", + "chunksPerFrame", + "uploadsPerFrame", + "onlyShowSignedChat", + "skipFriendsListPromo", + "postProcessEnable", + "soundCategory_animals", + "soundCategory_blocks", + "soundCategory_mobs", + "soundCategory_players", + "soundCategory_records", + "shieldAnimation", +]; + +pub(super) const KNOWN_VANILLA_KEYS: &[&str] = &[ + "version", + "ao", + "biomeBlendRadius", + "chunkSectionFadeInTime", + "cutoutLeaves", + "enableVsync", + "entityDistanceScaling", + "entityShadows", + "forceUnicodeFont", + "japaneseGlyphVariants", + "fov", + "fovEffectScale", + "darknessEffectScale", + "glintSpeed", + "glintStrength", + "preferredGraphicsBackend", + "graphicsPreset", + "prioritizeChunkUpdates", + "fullscreen", + "exclusiveFullscreen", + "macFullscreenMenuVisibility", + "gamma", + "guiScale", + "debugGuiScale", + "maxAnisotropyBit", + "textureFiltering", + "maxFps", + "improvedTransparency", + "inactivityFpsLimit", + "mipmapLevels", + "narrator", + "particles", + "reducedDebugInfo", + "renderClouds", + "cloudRange", + "renderDistance", + "simulationDistance", + "screenEffectScale", + "soundDevice", + "vignette", + "weatherRadius", + "autoJump", + "rotateWithMinecart", + "operatorItemsTab", + "autoSuggestions", + "chatColors", + "chatLinks", + "chatLinksPrompt", + "discrete_mouse_scroll", + "invertXMouse", + "invertYMouse", + "realmsNotifications", + "showSubtitles", + "directionalAudio", + "bobView", + "toggleCrouch", + "toggleSprint", + "toggleAttack", + "toggleUse", + "sprintWindow", + "darkMojangStudiosBackground", + "hideLightningFlashes", + "hideSplashTexts", + "mouseSensitivity", + "damageTiltStrength", + "highContrast", + "highContrastBlockOutline", + "narratorHotkey", + "resourcePacks", + "incompatibleResourcePacks", + "lastServer", + "lang", + "chatVisibility", + "chatOpacity", + "chatLineSpacing", + "textBackgroundOpacity", + "backgroundForChatOnly", + "hideServerAddress", + "advancedItemTooltips", + "pauseOnLostFocus", + "overrideWidth", + "overrideHeight", + "chatHeightFocused", + "chatDelay", + "chatHeightUnfocused", + "chatScale", + "chatWidth", + "notificationDisplayTime", + "useNativeTransport", + "mainHand", + "attackIndicator", + "tutorialStep", + "mouseWheelSensitivity", + "allowCursorChanges", + "quitShortcuts", + "ctrlClickEmulatesRightClick", + "glDebugVerbosity", + "skipMultiplayerWarning", + "hideMatchedNames", + "joinedFirstServer", + "syncChunkWrites", + "showAutosaveIndicator", + "allowServerListing", + "inGameNotification", + "sharePresence", + "onlyShowSecureChat", + "saveChatDrafts", + "panoramaScrollSpeed", + "telemetryOptInExtra", + "onboardAccessibility", + "menuBackgroundBlurriness", + "startedCleanly", + "musicToast", + "musicFrequency", + "key_key.attack", + "key_key.use", + "key_key.forward", + "key_key.left", + "key_key.back", + "key_key.right", + "key_key.jump", + "key_key.sneak", + "key_key.sprint", + "key_key.drop", + "key_key.inventory", + "key_key.chat", + "key_key.playerlist", + "key_key.pickItem", + "key_key.command", + "key_key.friends", + "key_key.socialInteractions", + "key_key.toggleGui", + "key_key.toggleSpectatorShaderEffects", + "key_key.screenshot", + "key_key.togglePerspective", + "key_key.smoothCamera", + "key_key.fullscreen", + "key_key.spectatorOutlines", + "key_key.spectatorHotbar", + "key_key.swapOffhand", + "key_key.saveToolbarActivator", + "key_key.loadToolbarActivator", + "key_key.advancements", + "key_key.quickActions", + "key_key.debug.overlay", + "key_key.debug.modifier", + "key_key.hotbar.1", + "key_key.hotbar.2", + "key_key.hotbar.3", + "key_key.hotbar.4", + "key_key.hotbar.5", + "key_key.hotbar.6", + "key_key.hotbar.7", + "key_key.hotbar.8", + "key_key.hotbar.9", + "key_key.debug.reloadChunk", + "key_key.debug.showHitboxes", + "key_key.debug.clearChat", + "key_key.debug.crash", + "key_key.debug.showChunkBorders", + "key_key.debug.showAdvancedTooltips", + "key_key.debug.copyRecreateCommand", + "key_key.debug.spectate", + "key_key.debug.switchGameMode", + "key_key.debug.debugOptions", + "key_key.debug.focusPause", + "key_key.debug.dumpDynamicTextures", + "key_key.debug.reloadResourcePacks", + "key_key.debug.profiling", + "key_key.debug.copyLocation", + "key_key.debug.dumpVersion", + "key_key.debug.profilingChart", + "key_key.debug.fpsCharts", + "key_key.debug.networkCharts", + "key_key.debug.lightmapTexture", + "key_key.debug.improvedTransparency", + "soundCategory_master", + "soundCategory_music", + "soundCategory_record", + "soundCategory_weather", + "soundCategory_block", + "soundCategory_hostile", + "soundCategory_neutral", + "soundCategory_player", + "soundCategory_ambient", + "soundCategory_voice", + "soundCategory_ui", + "modelPart_cape", + "modelPart_jacket", + "modelPart_left_sleeve", + "modelPart_right_sleeve", + "modelPart_left_pants_leg", + "modelPart_right_pants_leg", + "modelPart_hat", + "fullscreenResolution", +]; diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/legacy_key_bindings.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/legacy_key_bindings.rs new file mode 100644 index 0000000..f926d9d --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/legacy_key_bindings.rs @@ -0,0 +1,112 @@ +// Before Minecraft 1.13, options.txt stored key bindings as numbers. This table +// converts those numbers to the key names used by current versions. +pub(in crate::api::instance) const LEGACY_KEY_BINDINGS: &[(i32, &str)] = &[ + (0, "key.keyboard.unknown"), + (1, "key.keyboard.escape"), + (2, "key.keyboard.1"), + (3, "key.keyboard.2"), + (4, "key.keyboard.3"), + (5, "key.keyboard.4"), + (6, "key.keyboard.5"), + (7, "key.keyboard.6"), + (8, "key.keyboard.7"), + (9, "key.keyboard.8"), + (10, "key.keyboard.9"), + (11, "key.keyboard.0"), + (12, "key.keyboard.minus"), + (13, "key.keyboard.equal"), + (14, "key.keyboard.backspace"), + (15, "key.keyboard.tab"), + (16, "key.keyboard.q"), + (17, "key.keyboard.w"), + (18, "key.keyboard.e"), + (19, "key.keyboard.r"), + (20, "key.keyboard.t"), + (21, "key.keyboard.y"), + (22, "key.keyboard.u"), + (23, "key.keyboard.i"), + (24, "key.keyboard.o"), + (25, "key.keyboard.p"), + (26, "key.keyboard.left.bracket"), + (27, "key.keyboard.right.bracket"), + (28, "key.keyboard.enter"), + (29, "key.keyboard.left.control"), + (30, "key.keyboard.a"), + (31, "key.keyboard.s"), + (32, "key.keyboard.d"), + (33, "key.keyboard.f"), + (34, "key.keyboard.g"), + (35, "key.keyboard.h"), + (36, "key.keyboard.j"), + (37, "key.keyboard.k"), + (38, "key.keyboard.l"), + (39, "key.keyboard.semicolon"), + (40, "key.keyboard.apostrophe"), + (41, "key.keyboard.grave.accent"), + (42, "key.keyboard.left.shift"), + (43, "key.keyboard.backslash"), + (44, "key.keyboard.z"), + (45, "key.keyboard.x"), + (46, "key.keyboard.c"), + (47, "key.keyboard.v"), + (48, "key.keyboard.b"), + (49, "key.keyboard.n"), + (50, "key.keyboard.m"), + (51, "key.keyboard.comma"), + (52, "key.keyboard.period"), + (53, "key.keyboard.slash"), + (54, "key.keyboard.right.shift"), + (55, "key.keyboard.keypad.multiply"), + (56, "key.keyboard.left.alt"), + (57, "key.keyboard.space"), + (58, "key.keyboard.caps.lock"), + (59, "key.keyboard.f1"), + (60, "key.keyboard.f2"), + (61, "key.keyboard.f3"), + (62, "key.keyboard.f4"), + (63, "key.keyboard.f5"), + (64, "key.keyboard.f6"), + (65, "key.keyboard.f7"), + (66, "key.keyboard.f8"), + (67, "key.keyboard.f9"), + (68, "key.keyboard.f10"), + (69, "key.keyboard.num.lock"), + (70, "key.keyboard.scroll.lock"), + (71, "key.keyboard.keypad.7"), + (72, "key.keyboard.keypad.8"), + (73, "key.keyboard.keypad.9"), + (74, "key.keyboard.keypad.subtract"), + (75, "key.keyboard.keypad.4"), + (76, "key.keyboard.keypad.5"), + (77, "key.keyboard.keypad.6"), + (78, "key.keyboard.keypad.add"), + (79, "key.keyboard.keypad.1"), + (80, "key.keyboard.keypad.2"), + (81, "key.keyboard.keypad.3"), + (82, "key.keyboard.keypad.0"), + (83, "key.keyboard.keypad.decimal"), + (87, "key.keyboard.f11"), + (88, "key.keyboard.f12"), + (100, "key.keyboard.f13"), + (101, "key.keyboard.f14"), + (102, "key.keyboard.f15"), + (141, "key.keyboard.keypad.equal"), + (156, "key.keyboard.keypad.enter"), + (157, "key.keyboard.right.control"), + (181, "key.keyboard.keypad.divide"), + (183, "key.keyboard.print.screen"), + (184, "key.keyboard.right.alt"), + (197, "key.keyboard.pause"), + (199, "key.keyboard.home"), + (200, "key.keyboard.up"), + (201, "key.keyboard.page.up"), + (203, "key.keyboard.left"), + (205, "key.keyboard.right"), + (207, "key.keyboard.end"), + (208, "key.keyboard.down"), + (209, "key.keyboard.page.down"), + (210, "key.keyboard.insert"), + (211, "key.keyboard.delete"), + (219, "key.keyboard.left.win"), + (220, "key.keyboard.right.win"), +]; diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/mod.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/mod.rs new file mode 100644 index 0000000..4b4555d --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/mod.rs @@ -0,0 +1,452 @@ +//! Defines the Minecraft settings Modrinth knows how to read and write. +//! +//! File keys and version conversions live here. The frontend supplies the labels +//! and translations shown to the user. + +mod accessibility; +mod chat; +mod controls; +mod data_versions; +mod known_vanilla_keys; +mod legacy_key_bindings; +mod sound; +mod version_changes; +mod video; + +use super::api_types::{ + GameOptionEditorChoice, GameOptionEditorDefinition, GameOptionMappingKind, +}; + +pub(super) use data_versions::LEGACY_DATA_VERSIONS; +use known_vanilla_keys::{KNOWN_VANILLA_KEYS, NEVER_SYNC_KEYS}; +pub(in crate::api::instance) use legacy_key_bindings::LEGACY_KEY_BINDINGS; +pub(in crate::api::instance) use version_changes::*; + +#[derive(Clone, Copy)] +pub(super) enum SettingEditor { + Boolean, + UnboundedInteger, + Integer { + min: i64, + max: i64, + step: i64, + }, + UnboundedDecimal, + Decimal { + min: f64, + max: f64, + step: f64, + unit: Option<&'static str>, + }, + Enum(&'static [&'static str]), + Language, + KeyBinding, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub(super) enum ValueEncoding { + Bool, + Integer, + Decimal, + Enum(&'static [&'static str]), + QuotedEnum(&'static [&'static str]), + Text, + Graphics, + AmbientOcclusion, + ChatPreview, + Clouds, + GuiScale, + MenuBackgroundBlur, + MusicToast, + Fov, + KeyBinding, +} + +pub(super) struct SupportedSetting { + pub(super) id: &'static str, + pub(super) keys: &'static [&'static str], + pub(super) versioned_keys: &'static [VersionedKey], + /// The README's observed range: versions whose fresh options file contains + /// this key. Versioned settings carry the same ranges on each physical key. + pub(super) since: &'static str, + pub(super) until: &'static str, + pub(super) category: &'static str, + pub(super) default_on: bool, + pub(super) editor: SettingEditor, + pub(super) encoding: ValueEncoding, +} + +#[derive(Clone, Copy)] +pub(super) struct VersionedKey { + pub(super) key: &'static str, + pub(super) since: &'static str, + pub(super) until: &'static str, + pub(super) mapping: GameOptionMappingKind, +} + +pub(super) const BOOL: SettingEditor = SettingEditor::Boolean; +pub(super) const UNIT_INTERVAL: SettingEditor = SettingEditor::Decimal { + min: 0.0, + max: 1.0, + step: 0.01, + unit: Some("percent"), +}; + +pub(super) const fn setting( + id: &'static str, + keys: &'static [&'static str], + category: &'static str, + default_on: bool, + editor: SettingEditor, + encoding: ValueEncoding, +) -> SupportedSetting { + SupportedSetting { + id, + keys, + versioned_keys: &[], + since: "1.0", + until: "26.3", + category, + default_on, + editor, + encoding, + } +} + +pub(super) const fn ranged_setting( + id: &'static str, + keys: &'static [&'static str], + since: &'static str, + until: &'static str, + category: &'static str, + default_on: bool, + editor: SettingEditor, + encoding: ValueEncoding, +) -> SupportedSetting { + SupportedSetting { + id, + keys, + versioned_keys: &[], + since, + until, + category, + default_on, + editor, + encoding, + } +} + +pub(super) const fn versioned_setting( + id: &'static str, + keys: &'static [&'static str], + versioned_keys: &'static [VersionedKey], + category: &'static str, + default_on: bool, + editor: SettingEditor, + encoding: ValueEncoding, +) -> SupportedSetting { + SupportedSetting { + id, + keys, + versioned_keys, + since: "1.0", + until: "26.3", + category, + default_on, + editor, + encoding, + } +} + +const SETTING_GROUPS: &[&[SupportedSetting]] = &[ + video::SETTINGS, + sound::SETTINGS, + controls::SETTINGS, + chat::SETTINGS, + accessibility::SETTINGS, + GENERAL_SETTINGS, +]; + +const MAIN_HAND: &[&str] = &["left", "right"]; +const SHARE_PRESENCE: &[&str] = &["all", "limited", "none"]; + +// These settings all use the common value formats above, so they can stay together. +const GENERAL_SETTINGS: &[SupportedSetting] = &[ + ranged_setting( + "main_hand", + &["mainHand"], + "1.9", + "26.3", + "skin_customization", + true, + SettingEditor::Enum(MAIN_HAND), + ValueEncoding::Enum(MAIN_HAND), + ), + versioned_setting( + "cape", + &["modelPart_cape", "showCape"], + version_changes::CAPE_KEYS, + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "hat", + &["modelPart_hat"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "jacket", + &["modelPart_jacket"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "left_sleeve", + &["modelPart_left_sleeve"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "right_sleeve", + &["modelPart_right_sleeve"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "left_pants_leg", + &["modelPart_left_pants_leg"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "right_pants_leg", + &["modelPart_right_pants_leg"], + "1.8", + "26.3", + "skin_customization", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "allow_server_listing", + &["allowServerListing"], + "1.18", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "realms_notifications", + &["realmsNotifications"], + "1.8.9", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "hide_server_address", + &["hideServerAddress"], + "1.3.2", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "server_textures", + &["serverTextures"], + "1.3.1", + "1.7.5", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "snooper", + &["snooperEnabled"], + "1.3.1", + "1.17.1", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "extra_telemetry", + &["telemetryOptInExtra"], + "1.19.3", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "in_game_notifications", + &["inGameNotification"], + "26.2", + "26.3", + "online", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "share_presence", + &["sharePresence"], + "26.2", + "26.3", + "online", + true, + SettingEditor::Enum(SHARE_PRESENCE), + ValueEncoding::QuotedEnum(SHARE_PRESENCE), + ), +]; + +pub(super) fn all_supported_settings() +-> impl Iterator { + SETTING_GROUPS.iter().flat_map(|group| group.iter()) +} + +pub(super) fn setting_by_id( + option_id: &str, +) -> Option<&'static SupportedSetting> { + all_supported_settings().find(|setting| setting.id == option_id) +} + +pub(super) fn setting_by_file_key( + key: &str, +) -> Option<&'static SupportedSetting> { + all_supported_settings().find(|setting| setting.keys.contains(&key)) +} + +pub(super) fn setting_available_for_version( + setting: &SupportedSetting, + version: &str, +) -> bool { + if !setting.versioned_keys.is_empty() { + return physical_variant_for_version(setting, version).is_some(); + } + let Some(version) = release_version(version) else { + return false; + }; + release_version(setting.since).is_some_and(|since| since <= version) + && release_version(setting.until).is_some_and(|until| version <= until) +} + +pub(super) fn target_key_for_version<'a>( + setting: &'a SupportedSetting, + version: &str, +) -> Option<&'a str> { + if setting.versioned_keys.is_empty() { + setting_available_for_version(setting, version) + .then(|| setting.keys.first().copied()) + .flatten() + } else { + physical_variant_for_version(setting, version) + .map(|variant| variant.key) + } +} + +pub(super) fn is_never_sync_key(key: &str) -> bool { + NEVER_SYNC_KEYS.contains(&key) + || KNOWN_VANILLA_KEYS.contains(&key) + || key.starts_with("skip") + || key.starts_with("hideBundleTutorial") + || key.starts_with("key_") && !key.starts_with("key_key.") +} + +pub(super) fn editor_for( + setting: &SupportedSetting, +) -> GameOptionEditorDefinition { + let mut editor = GameOptionEditorDefinition { + type_: String::new(), + min: None, + max: None, + step: None, + unit: None, + choices: Vec::new(), + }; + match setting.editor { + SettingEditor::Boolean => editor.type_ = "bool".to_string(), + SettingEditor::UnboundedInteger => { + editor.type_ = "integer".to_string(); + editor.step = Some(1.0); + } + SettingEditor::Integer { min, max, step } => { + editor.type_ = "integer".to_string(); + editor.min = Some(min as f64); + editor.max = Some(max as f64); + editor.step = Some(step as f64); + } + SettingEditor::UnboundedDecimal => { + editor.type_ = "decimal".to_string(); + editor.step = Some(0.01); + } + SettingEditor::Decimal { + min, + max, + step, + unit, + } => { + editor.type_ = "decimal".to_string(); + editor.min = Some(min); + editor.max = Some(max); + editor.step = Some(step); + editor.unit = unit.map(str::to_string); + } + SettingEditor::Enum(choices) => { + editor.type_ = "enum".to_string(); + editor.choices = choices + .iter() + .map(|value| GameOptionEditorChoice { + value: (*value).to_string(), + }) + .collect(); + } + SettingEditor::Language => editor.type_ = "text".to_string(), + SettingEditor::KeyBinding => editor.type_ = "key_binding".to_string(), + } + editor +} + +pub(super) fn custom_setting_editor() -> GameOptionEditorDefinition { + GameOptionEditorDefinition { + type_: "external_raw".to_string(), + min: None, + max: None, + step: None, + unit: None, + choices: Vec::new(), + } +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/sound.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/sound.rs new file mode 100644 index 0000000..30774df --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/sound.rs @@ -0,0 +1,177 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, UNIT_INTERVAL, ValueEncoding, + ranged_setting, version_changes, versioned_setting, +}; + +const MUSIC_TOAST: &[&str] = &["never", "pause", "pause_and_toast"]; +const MUSIC_FREQUENCY: &[&str] = &["CONSTANT", "DEFAULT", "FREQUENT"]; + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + ranged_setting( + "language", + &["lang"], + "1.1", + "26.3", + "language", + true, + SettingEditor::Language, + ValueEncoding::Text, + ), + ranged_setting( + "force_unicode_font", + &["forceUnicodeFont"], + "1.7.2", + "26.3", + "language", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "japanese_glyph_variants", + &["japaneseGlyphVariants"], + "1.20.5", + "26.3", + "language", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "directional_audio", + &["directionalAudio"], + "1.19", + "26.3", + "music_and_sound", + true, + BOOL, + ValueEncoding::Bool, + ), + versioned_setting( + "music_toast", + &["musicToast", "showNowPlayingToast"], + version_changes::MUSIC_TOAST_KEYS, + "music_and_sound", + true, + SettingEditor::Enum(MUSIC_TOAST), + ValueEncoding::MusicToast, + ), + ranged_setting( + "music_frequency", + &["musicFrequency"], + "1.21.6", + "26.3", + "music_and_sound", + true, + SettingEditor::Enum(MUSIC_FREQUENCY), + ValueEncoding::QuotedEnum(MUSIC_FREQUENCY), + ), + versioned_setting( + "master_volume", + &["soundCategory_master", "sound"], + version_changes::MASTER_VOLUME_KEYS, + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + versioned_setting( + "music_volume", + &["soundCategory_music", "music"], + version_changes::MUSIC_VOLUME_KEYS, + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "record_volume", + &["soundCategory_record"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "weather_volume", + &["soundCategory_weather"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "blocks_volume", + &["soundCategory_block"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "hostile_volume", + &["soundCategory_hostile"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "neutral_volume", + &["soundCategory_neutral"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "players_volume", + &["soundCategory_player"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "ambient_volume", + &["soundCategory_ambient"], + "1.7.2", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "voice_volume", + &["soundCategory_voice"], + "1.9", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "ui_volume", + &["soundCategory_ui"], + "1.21.6", + "26.3", + "music_and_sound", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), +]; diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/version_changes.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/version_changes.rs new file mode 100644 index 0000000..a0fc60b --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/version_changes.rs @@ -0,0 +1,969 @@ +//! Handles settings Minecraft renamed or stored differently between versions. + +use super::super::api_types::canonical_values_equal; +use super::super::options_file::{ + GameOptionsDocument, input_error, validate_raw_key_value, +}; +use super::{ + GameOptionMappingKind, LEGACY_KEY_BINDINGS, SettingEditor, + SupportedSetting, ValueEncoding, VersionedKey, + setting_available_for_version, +}; +use crate::state::{CanonicalValue, StoredOption}; + +const GRAPHICS_VALUES: &[&str] = &["fast", "fancy", "fabulous", "custom"]; +const CLOUD_VALUES: &[&str] = &["false", "fast", "true"]; +const MUSIC_TOAST_VALUES: &[&str] = &["never", "pause", "pause_and_toast"]; + +fn decode_string_token(raw: &str) -> Option { + if raw.starts_with('"') { + serde_json::from_str(raw).ok() + } else { + Some(raw.to_string()) + } +} + +fn encode_string_token(value: &str) -> Option { + serde_json::to_string(value).ok() +} + +pub(in crate::api::instance) const GRAPHICS_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "fancyGraphics", + since: "1.0", + until: "1.15.2", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "graphicsMode", + since: "1.16", + until: "1.21.10", + mapping: GameOptionMappingKind::Migrated, + }, + VersionedKey { + key: "graphicsPreset", + since: "1.21.11", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const AMBIENT_OCCLUSION_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "ao", + since: "1.0", + until: "1.4.7", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "ao", + since: "1.5.1", + until: "1.19.2", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "ao", + since: "1.19.3", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const FOV_KEYS: &[VersionedKey] = + &[VersionedKey { + key: "fov", + since: "1.0", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }]; + +pub(in crate::api::instance) const CLOUD_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "clouds", + since: "1.0", + until: "1.7.10", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "renderClouds", + since: "1.8", + until: "1.8", + mapping: GameOptionMappingKind::Migrated, + }, + VersionedKey { + key: "renderClouds", + since: "1.8.1", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const CHAT_PREVIEW_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "chatPreview", + since: "1.19", + until: "1.19", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "chatPreview", + since: "1.19.1", + until: "1.19.2", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const MENU_BACKGROUND_BLUR_KEYS: + &[VersionedKey] = &[ + VersionedKey { + key: "menuBackgroundBlurriness", + since: "1.20.5", + until: "1.20.6", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "menuBackgroundBlurriness", + since: "1.21", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const MASTER_VOLUME_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "sound", + since: "1.0", + until: "1.6.4", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "soundCategory_master", + since: "1.7.2", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const MUSIC_VOLUME_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "music", + since: "1.0", + until: "1.6.4", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "soundCategory_music", + since: "1.7.2", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const CAPE_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "showCape", + since: "1.4.2", + until: "1.7.10", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "modelPart_cape", + since: "1.8", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const MUSIC_TOAST_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "showNowPlayingToast", + since: "1.21.6", + until: "1.21.10", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "musicToast", + since: "1.21.11", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) const SWAP_OFFHAND_KEYS: &[VersionedKey] = &[ + VersionedKey { + key: "key_key.swapHands", + since: "1.9", + until: "1.15.2", + mapping: GameOptionMappingKind::Legacy, + }, + VersionedKey { + key: "key_key.swapOffhand", + since: "1.16", + until: "26.3", + mapping: GameOptionMappingKind::Direct, + }, +]; + +pub(in crate::api::instance) fn release_version( + version: &str, +) -> Option<(u32, u32, u32)> { + let components = version + .split('.') + .map(|component| component.parse::()) + .collect::, _>>() + .ok()?; + match components.as_slice() { + [major, minor] => Some((*major, *minor, 0)), + [major, minor, patch] => Some((*major, *minor, *patch)), + _ => None, + } +} + +pub(in crate::api::instance) fn physical_variant_for_version<'a>( + definition: &'a SupportedSetting, + version: &str, +) -> Option<&'a VersionedKey> { + let version = release_version(version)?; + definition.versioned_keys.iter().find(|variant| { + let Some(since) = release_version(variant.since) else { + return false; + }; + let Some(until) = release_version(variant.until) else { + return false; + }; + version >= since && version <= until + }) +} + +pub(in crate::api::instance) fn physical_variant_for_present_key<'a>( + definition: &'a SupportedSetting, + key: &str, + version: &str, +) -> Option<&'a VersionedKey> { + let version = release_version(version)?; + definition.versioned_keys.iter().rev().find(|variant| { + variant.key == key + && release_version(variant.since) + .is_some_and(|since| since <= version) + && release_version(variant.until) + .is_some_and(|until| version <= until) + }) +} + +pub(in crate::api::instance) fn uses_modern_key_tokens(version: &str) -> bool { + release_version(version).is_some_and(|version| version >= (1, 13, 0)) +} + +pub(in crate::api::instance) fn split_key_binding( + value: &str, +) -> (&str, Option<&str>) { + value + .split_once(':') + .map_or((value, None), |(key, modifier)| (key, Some(modifier))) +} + +pub(in crate::api::instance) fn append_key_modifier( + mut key: String, + modifier: Option<&str>, +) -> String { + if let Some(modifier) = modifier { + key.push(':'); + key.push_str(modifier); + } + key +} + +pub(in crate::api::instance) fn legacy_key_binding_to_token( + value: &str, +) -> Option { + let (key, modifier) = split_key_binding(value); + let key = key.parse::().ok()?; + let token = match key { + -100 => "key.mouse.left".to_string(), + -99 => "key.mouse.right".to_string(), + -98 => "key.mouse.middle".to_string(), + -97..=-85 => format!("key.mouse.{}", key + 101), + _ => LEGACY_KEY_BINDINGS + .iter() + .find(|(legacy, _)| *legacy == key) + .map(|(_, token)| (*token).to_string())?, + }; + Some(append_key_modifier(token, modifier)) +} + +pub(in crate::api::instance) fn token_key_binding_to_legacy( + value: &str, +) -> Option { + let (token, modifier) = split_key_binding(value); + let key = match token { + "key.mouse.left" => -100, + "key.mouse.right" => -99, + "key.mouse.middle" => -98, + _ => { + if let Some(button) = token.strip_prefix("key.mouse.") { + let button = button.parse::().ok()?; + if !(4..=16).contains(&button) { + return None; + } + button - 101 + } else { + LEGACY_KEY_BINDINGS + .iter() + .find(|(_, candidate)| *candidate == token) + .map(|(legacy, _)| *legacy)? + } + } + }; + Some(append_key_modifier(key.to_string(), modifier)) +} + +pub(in crate::api::instance) fn valid_modern_key_binding(value: &str) -> bool { + let (key, modifier) = split_key_binding(value); + let key_valid = ["key.keyboard.", "key.mouse.", "scancode."] + .into_iter() + .find_map(|prefix| key.strip_prefix(prefix)) + .is_some_and(|identifier| { + !identifier.is_empty() + && identifier.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '.' | '_' | '-') + }) + }); + key_valid + && modifier.is_none_or(|modifier| { + !modifier.is_empty() + && modifier.len() <= 64 + && modifier.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '.' | '_' | '-' | ':' | '+') + }) + }) +} + +pub(in crate::api::instance) fn supported_settings_cover_game_version( + version: &str, +) -> bool { + if version.contains('w') || version.contains("snapshot") { + return false; + } + let components = version + .split('.') + .map(|component| component.parse::()) + .collect::, _>>(); + let Ok(components) = components else { + return false; + }; + match components.as_slice() { + [1, minor] => *minor <= 21, + [1, minor, patch] => *minor < 21 || (*minor == 21 && *patch <= 11), + [26, minor] | [26, minor, _] => (1..=3).contains(minor), + _ => false, + } +} + +pub(in crate::api::instance) fn decode_value( + definition: &SupportedSetting, + physical_key: &str, + raw: &str, +) -> Option { + match definition.encoding { + ValueEncoding::Bool => match raw { + "true" => Some(CanonicalValue::Bool(true)), + "false" => Some(CanonicalValue::Bool(false)), + _ => None, + }, + ValueEncoding::Integer => { + raw.parse::().ok().map(CanonicalValue::Integer) + } + ValueEncoding::Decimal => raw + .parse::() + .ok() + .filter(|value| value.is_finite()) + .map(|_| CanonicalValue::Decimal(raw.to_string())), + ValueEncoding::Enum(choices) => { + let value = decode_string_token(raw)?; + choices + .contains(&value.as_str()) + .then_some(CanonicalValue::Enum(value)) + } + ValueEncoding::QuotedEnum(choices) => { + let value = decode_string_token(raw)?; + choices + .contains(&value.as_str()) + .then_some(CanonicalValue::Enum(value)) + } + ValueEncoding::Text => Some(CanonicalValue::Text(raw.to_string())), + ValueEncoding::KeyBinding => { + if valid_modern_key_binding(raw) { + Some(CanonicalValue::KeyBinding(raw.to_string())) + } else { + legacy_key_binding_to_token(raw).map(CanonicalValue::KeyBinding) + } + } + ValueEncoding::Fov => raw.parse::().ok().and_then(|value| { + if value.fract() == 0.0 && (30.0..=110.0).contains(&value) { + return Some(CanonicalValue::Integer(value as i64)); + } + let degrees = value * 40.0 + 70.0; + (value.is_finite() + && (-1.0..=1.0).contains(&value) + && (degrees - degrees.round()).abs() <= 0.000_001) + .then(|| CanonicalValue::Integer(degrees.round() as i64)) + }), + ValueEncoding::GuiScale => { + raw.parse::().ok().map(CanonicalValue::Integer) + } + ValueEncoding::MenuBackgroundBlur => raw + .parse::() + .ok() + .filter(|value| value.is_finite() && value.fract() == 0.0) + .map(|value| CanonicalValue::Integer(value as i64)), + ValueEncoding::Graphics => { + let raw = decode_string_token(raw)?; + let value = match physical_key { + "fancyGraphics" => match raw.as_str() { + "false" => "fast", + "true" => "fancy", + _ => return None, + }, + "graphicsMode" => match raw.as_str() { + "0" => "fast", + "1" => "fancy", + "2" => "fabulous", + _ => return None, + }, + "graphicsPreset" if GRAPHICS_VALUES.contains(&raw.as_str()) => { + raw.as_str() + } + _ => return None, + }; + Some(CanonicalValue::Enum(value.to_string())) + } + ValueEncoding::AmbientOcclusion => { + let value = match raw { + "false" | "0" => "off", + "true" => "on", + "1" => "minimum", + "2" => "maximum", + _ => return None, + }; + Some(CanonicalValue::Enum(value.to_string())) + } + ValueEncoding::ChatPreview => { + let value = match raw { + "false" | "0" => "0", + "1" => "1", + "true" | "2" => "2", + _ => return None, + }; + Some(CanonicalValue::Enum(value.to_string())) + } + ValueEncoding::Clouds => { + let raw = decode_string_token(raw)?; + CLOUD_VALUES + .contains(&raw.as_str()) + .then_some(CanonicalValue::Enum(raw)) + } + ValueEncoding::MusicToast => { + let raw = decode_string_token(raw)?; + let value = match (physical_key, raw.as_str()) { + ("showNowPlayingToast", "false") => "never", + ("showNowPlayingToast", "true") => "pause_and_toast", + ("musicToast", "never" | "pause" | "pause_and_toast") => { + raw.as_str() + } + _ => return None, + }; + Some(CanonicalValue::Enum(value.to_string())) + } + } +} + +pub(in crate::api::instance) fn encode_value( + definition: &SupportedSetting, + physical_key: &str, + value: &CanonicalValue, + game_version: &str, + current_raw: Option<&str>, +) -> Option { + if !setting_available_for_version(definition, game_version) { + return None; + } + if let Some(current_raw) = current_raw + && physical_representation_supported_for_target( + definition, + physical_key, + current_raw, + game_version, + ) + && decode_value(definition, physical_key, current_raw) + .as_ref() + .is_some_and(|current| canonical_values_equal(current, value)) + { + return Some(current_raw.to_string()); + } + match (definition.encoding, value) { + (ValueEncoding::Bool, CanonicalValue::Bool(value)) => { + Some(value.to_string()) + } + (ValueEncoding::Integer, CanonicalValue::Integer(value)) => { + Some(value.to_string()) + } + (ValueEncoding::Decimal, CanonicalValue::Decimal(value)) => { + Some(value.clone()) + } + (ValueEncoding::Decimal, CanonicalValue::Integer(value)) => { + Some(value.to_string()) + } + (ValueEncoding::Enum(choices), CanonicalValue::Enum(value)) + if choices.contains(&value.as_str()) => + { + if current_raw.is_some_and(|raw| raw.starts_with('"')) { + encode_string_token(value) + } else { + Some(value.clone()) + } + } + (ValueEncoding::QuotedEnum(choices), CanonicalValue::Enum(value)) + if choices.contains(&value.as_str()) => + { + encode_string_token(value) + } + (ValueEncoding::Text, CanonicalValue::Text(value)) => { + Some(value.clone()) + } + (ValueEncoding::KeyBinding, CanonicalValue::KeyBinding(value)) + if valid_modern_key_binding(value) => + { + if !uses_modern_key_tokens(game_version) { + token_key_binding_to_legacy(value) + } else { + Some(value.clone()) + } + } + (ValueEncoding::Fov, CanonicalValue::Integer(value)) + if (30..=110).contains(value) => + { + let normalized = (*value as f64 - 70.0) / 40.0; + Some(format_decimal(normalized)) + } + (ValueEncoding::GuiScale, CanonicalValue::Integer(value)) + if (0..=8).contains(value) + && (release_version(game_version) + .is_some_and(|version| version >= (1, 13, 0)) + || *value <= 3) => + { + Some(value.to_string()) + } + (ValueEncoding::MenuBackgroundBlur, CanonicalValue::Integer(value)) + if (0..=10).contains(value) => + { + if release_version(game_version) + .is_some_and(|version| version >= (1, 21, 0)) + { + Some(value.to_string()) + } else { + Some(format!("{value}.0")) + } + } + (ValueEncoding::Graphics, CanonicalValue::Enum(value)) => { + match physical_key { + "fancyGraphics" => match value.as_str() { + "fast" => Some("false".to_string()), + "fancy" => Some("true".to_string()), + _ => None, + }, + "graphicsMode" => match value.as_str() { + "fast" => Some("0".to_string()), + "fancy" => Some("1".to_string()), + "fabulous" => Some("2".to_string()), + _ => None, + }, + "graphicsPreset" + if GRAPHICS_VALUES.contains(&value.as_str()) => + { + encode_string_token(value) + } + _ => None, + } + } + (ValueEncoding::AmbientOcclusion, CanonicalValue::Enum(value)) => { + let version = release_version(game_version)?; + if ((1, 5, 1)..=(1, 19, 2)).contains(&version) { + match value.as_str() { + "off" => Some("0".to_string()), + "minimum" => Some("1".to_string()), + "maximum" => Some("2".to_string()), + _ => None, + } + } else { + match value.as_str() { + "off" => Some("false".to_string()), + "on" => Some("true".to_string()), + _ => None, + } + } + } + (ValueEncoding::ChatPreview, CanonicalValue::Enum(value)) => { + if release_version(game_version) == Some((1, 19, 0)) { + match value.as_str() { + "0" => Some("false".to_string()), + "2" => Some("true".to_string()), + _ => None, + } + } else if matches!(value.as_str(), "0" | "1" | "2") { + Some(value.clone()) + } else { + None + } + } + (ValueEncoding::Clouds, CanonicalValue::Enum(value)) => { + let version = release_version(game_version)?; + if version <= (1, 8, 0) { + match value.as_str() { + "false" | "true" => Some(value.clone()), + _ => None, + } + } else if CLOUD_VALUES.contains(&value.as_str()) { + Some(value.clone()) + } else { + None + } + } + (ValueEncoding::MusicToast, CanonicalValue::Enum(value)) => { + match physical_key { + "showNowPlayingToast" => match value.as_str() { + "never" => Some("false".to_string()), + "pause_and_toast" => Some("true".to_string()), + _ => None, + }, + "musicToast" + if MUSIC_TOAST_VALUES.contains(&value.as_str()) => + { + encode_string_token(value) + } + _ => None, + } + } + _ => None, + } +} + +fn validate_value( + definition: Option<&SupportedSetting>, + value: &CanonicalValue, + enforce_editor_increment: bool, +) -> crate::Result<()> { + if let Some(definition) = definition { + let type_valid = match definition.encoding { + ValueEncoding::Bool => matches!(value, CanonicalValue::Bool(_)), + ValueEncoding::Integer + | ValueEncoding::GuiScale + | ValueEncoding::MenuBackgroundBlur => { + matches!(value, CanonicalValue::Integer(_)) + } + ValueEncoding::Decimal => matches!( + value, + CanonicalValue::Decimal(_) | CanonicalValue::Integer(_) + ), + ValueEncoding::Enum(_) + | ValueEncoding::QuotedEnum(_) + | ValueEncoding::Graphics + | ValueEncoding::AmbientOcclusion + | ValueEncoding::ChatPreview + | ValueEncoding::Clouds + | ValueEncoding::MusicToast => { + matches!(value, CanonicalValue::Enum(_)) + } + ValueEncoding::Text => matches!(value, CanonicalValue::Text(_)), + ValueEncoding::Fov => matches!(value, CanonicalValue::Integer(_)), + ValueEncoding::KeyBinding => { + matches!(value, CanonicalValue::KeyBinding(_)) + } + }; + if !type_valid { + return Err(input_error(format!( + "Invalid value type for {}", + definition.id + ))); + } + match (definition.editor, value) { + ( + SettingEditor::UnboundedDecimal, + CanonicalValue::Decimal(value), + ) if value + .parse::() + .ok() + .is_none_or(|value| !value.is_finite()) => + { + return Err(input_error("Invalid decimal value")); + } + ( + SettingEditor::Integer { min, max, step }, + CanonicalValue::Integer(value), + ) if *value < min + || *value > max + || enforce_editor_increment + && step > 0 + && (*value - min) % step != 0 => + { + return Err(input_error(format!( + "{} is outside its supported range or increment", + definition.id + ))); + } + ( + SettingEditor::Decimal { min, max, step, .. }, + CanonicalValue::Decimal(value), + ) => { + let parsed = value + .parse::() + .map_err(|_| input_error("Invalid decimal value"))?; + let increment = (parsed - min) / step; + if !parsed.is_finite() + || parsed < min + || parsed > max + || enforce_editor_increment + && step > 0.0 + && (increment - increment.round()).abs() > 0.000_001 + { + return Err(input_error(format!( + "{} is outside its supported range or increment", + definition.id + ))); + } + } + ( + SettingEditor::Decimal { min, max, step, .. }, + CanonicalValue::Integer(value), + ) => { + let parsed = *value as f64; + let increment = (parsed - min) / step; + if parsed < min + || parsed > max + || enforce_editor_increment + && step > 0.0 + && (increment - increment.round()).abs() > 0.000_001 + { + return Err(input_error(format!( + "{} is outside its supported range or increment", + definition.id + ))); + } + } + (SettingEditor::Enum(choices), CanonicalValue::Enum(value)) + if !choices.contains(&value.as_str()) => + { + return Err(input_error(format!( + "Invalid choice for {}", + definition.id + ))); + } + (SettingEditor::Language, CanonicalValue::Text(value)) + if value.is_empty() + || value.len() > 64 + || value.chars().any(char::is_control) => + { + return Err(input_error("Invalid language value")); + } + (SettingEditor::KeyBinding, CanonicalValue::KeyBinding(value)) + if value.len() > 256 + || value.contains(['\r', '\n']) + || !valid_modern_key_binding(value) => + { + return Err(input_error("Invalid key binding value")); + } + _ => {} + } + } else if let CanonicalValue::ExternalRaw(raw) = value { + validate_raw_key_value("external", raw)?; + } else { + return Err(input_error("Custom settings require a raw string value")); + } + Ok(()) +} + +pub(in crate::api::instance) fn validate_canonical_value( + definition: Option<&SupportedSetting>, + value: &CanonicalValue, +) -> crate::Result<()> { + validate_value(definition, value, true) +} + +pub(in crate::api::instance) fn validate_file_value( + definition: Option<&SupportedSetting>, + value: &CanonicalValue, +) -> crate::Result<()> { + validate_value(definition, value, false) +} + +pub(in crate::api::instance) fn decode_value_for_version( + definition: &SupportedSetting, + physical_key: &str, + raw: &str, + game_version: &str, +) -> Option { + if !setting_available_for_version(definition, game_version) { + return None; + } + let value = decode_value(definition, physical_key, raw)?; + validate_file_value(Some(definition), &value).ok()?; + let target_key = if definition.versioned_keys.is_empty() { + physical_key + } else { + physical_variant_for_version(definition, game_version)?.key + }; + encode_value(definition, target_key, &value, game_version, None)?; + Some(value) +} + +pub(in crate::api::instance) fn target_physical_key( + definition: Option<&SupportedSetting>, + stored: &StoredOption, + document: &GameOptionsDocument, + game_version: &str, +) -> Option { + if let Some(definition) = definition { + if !setting_available_for_version(definition, game_version) { + return None; + } + if !definition.versioned_keys.is_empty() { + observed_physical_key(definition, document, game_version)?; + return physical_variant_for_version(definition, game_version) + .map(|variant| variant.key.to_string()); + } + definition + .keys + .iter() + .find(|key| document.value(key).is_some()) + .map(|key| (*key).to_string()) + } else { + stored.raw_key.clone() + } +} + +pub(in crate::api::instance) fn observed_physical_key( + definition: &SupportedSetting, + document: &GameOptionsDocument, + game_version: &str, +) -> Option { + if !setting_available_for_version(definition, game_version) { + return None; + } + if let Some(target) = physical_variant_for_version(definition, game_version) + && document.value(target.key).is_some() + { + return Some(target.key.to_string()); + } + definition + .keys + .iter() + .find(|key| document.value(key).is_some()) + .map(|key| (*key).to_string()) +} + +pub(in crate::api::instance) fn alias_migration_needed( + definition: &SupportedSetting, + document: &GameOptionsDocument, + target_key: &str, +) -> bool { + document.value(target_key).is_none() + && definition + .keys + .iter() + .any(|key| *key != target_key && document.value(key).is_some()) +} + +pub(in crate::api::instance) fn physical_representation_supported_for_target( + definition: &SupportedSetting, + physical_key: &str, + raw: &str, + game_version: &str, +) -> bool { + if !setting_available_for_version(definition, game_version) { + return false; + } + let Some(target_version) = release_version(game_version) else { + return false; + }; + if matches!(definition.encoding, ValueEncoding::KeyBinding) { + return if target_version >= (1, 13, 0) { + valid_modern_key_binding(raw) + } else { + legacy_key_binding_to_token(raw).is_some() + }; + } + if matches!(definition.encoding, ValueEncoding::AmbientOcclusion) { + return if ((1, 5, 1)..=(1, 19, 2)).contains(&target_version) { + matches!(raw, "0" | "1" | "2") + } else { + matches!(raw, "true" | "false") + }; + } + if matches!(definition.encoding, ValueEncoding::Fov) { + return raw.parse::().is_ok_and(|value| { + value.is_finite() && (-1.0..=1.0).contains(&value) + }); + } + if matches!(definition.encoding, ValueEncoding::ChatPreview) { + return if target_version == (1, 19, 0) { + matches!(raw, "true" | "false") + } else { + matches!(raw, "0" | "1" | "2") + }; + } + if matches!(definition.encoding, ValueEncoding::GuiScale) { + return raw.parse::().is_ok_and(|value| { + if target_version >= (1, 13, 0) { + (0..=8).contains(&value) + } else { + (0..=3).contains(&value) + } + }); + } + if matches!(definition.encoding, ValueEncoding::MenuBackgroundBlur) { + return if target_version >= (1, 21, 0) { + raw.parse::() + .is_ok_and(|value| (0..=10).contains(&value)) + } else { + raw.parse::().is_ok_and(|value| { + value.is_finite() + && value.fract() == 0.0 + && (0.0..=10.0).contains(&value) + }) + }; + } + if matches!(definition.encoding, ValueEncoding::Clouds) { + return if target_version <= (1, 8, 0) { + matches!(raw, "true" | "false") + } else { + decode_string_token(raw) + .is_some_and(|value| CLOUD_VALUES.contains(&value.as_str())) + }; + } + + let mut variants = definition + .versioned_keys + .iter() + .filter(|variant| variant.key == physical_key) + .peekable(); + if variants.peek().is_none() { + return true; + } + variants.any(|variant| { + release_version(variant.since) + .is_some_and(|since| since <= target_version) + && release_version(variant.until) + .is_some_and(|until| target_version <= until) + }) +} + +pub(in crate::api::instance) fn format_decimal(value: f64) -> String { + let value = format!("{value:.8}"); + let value = value.trim_end_matches('0').trim_end_matches('.'); + if value == "-0" { + "0".to_string() + } else { + value.to_string() + } +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/catalog/video.rs b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/video.rs new file mode 100644 index 0000000..7a2a7b5 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/catalog/video.rs @@ -0,0 +1,450 @@ +use super::{ + BOOL, SettingEditor, SupportedSetting, UNIT_INTERVAL, ValueEncoding, + ranged_setting, setting, version_changes, versioned_setting, +}; + +const GRAPHICS: &[&str] = &["fast", "fancy", "fabulous", "custom"]; +const AMBIENT_OCCLUSION: &[&str] = &["off", "on", "minimum", "maximum"]; +const PARTICLES: &[&str] = &["0", "1", "2"]; +const CLOUDS: &[&str] = &["false", "fast", "true"]; +const ATTACK_INDICATOR: &[&str] = &["0", "1", "2"]; +const INACTIVITY_FPS_LIMIT: &[&str] = &["afk", "minimized"]; +const PRIORITIZE_CHUNK_UPDATES: &[&str] = &["0", "1", "2"]; +const TEXTURE_FILTERING: &[&str] = &["0", "1", "2"]; +const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"]; +const LEGACY_FPS_LIMIT: &[&str] = &["0", "1", "2"]; +const LEGACY_VIEW_DISTANCE: &[&str] = &["0", "1", "2", "3"]; + +pub(super) const SETTINGS: &[SupportedSetting] = &[ + versioned_setting( + "fov", + &["fov"], + version_changes::FOV_KEYS, + "video", + true, + SettingEditor::Integer { + min: 30, + max: 110, + step: 1, + }, + ValueEncoding::Fov, + ), + versioned_setting( + "graphics", + &["graphicsPreset", "graphicsMode", "fancyGraphics"], + version_changes::GRAPHICS_KEYS, + "video", + true, + SettingEditor::Enum(GRAPHICS), + ValueEncoding::Graphics, + ), + versioned_setting( + "ambient_occlusion", + &["ao"], + version_changes::AMBIENT_OCCLUSION_KEYS, + "video", + true, + SettingEditor::Enum(AMBIENT_OCCLUSION), + ValueEncoding::AmbientOcclusion, + ), + setting( + "brightness", + &["gamma"], + "video", + true, + UNIT_INTERVAL, + ValueEncoding::Decimal, + ), + ranged_setting( + "render_distance", + &["renderDistance"], + "1.7.2", + "26.3", + "video", + false, + SettingEditor::Integer { + min: 2, + max: 64, + step: 1, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "legacy_view_distance", + &["viewDistance"], + "1.0", + "1.6.4", + "video", + false, + SettingEditor::Enum(LEGACY_VIEW_DISTANCE), + ValueEncoding::Enum(LEGACY_VIEW_DISTANCE), + ), + ranged_setting( + "simulation_distance", + &["simulationDistance"], + "1.18", + "26.3", + "video", + false, + SettingEditor::Integer { + min: 2, + max: 64, + step: 1, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "entity_distance", + &["entityDistanceScaling"], + "1.16", + "26.3", + "video", + false, + SettingEditor::Decimal { + min: 0.5, + max: 5.0, + step: 0.25, + unit: Some("multiplier"), + }, + ValueEncoding::Decimal, + ), + setting( + "gui_scale", + &["guiScale"], + "video", + true, + SettingEditor::Integer { + min: 0, + max: 8, + step: 1, + }, + ValueEncoding::GuiScale, + ), + ranged_setting( + "debug_gui_scale", + &["debugGuiScale"], + "26.3", + "26.3", + "video", + true, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "graphics_backend", + &["preferredGraphicsBackend"], + "26.2", + "26.3", + "video", + false, + SettingEditor::Enum(GRAPHICS_BACKEND), + ValueEncoding::QuotedEnum(GRAPHICS_BACKEND), + ), + setting( + "particles", + &["particles"], + "video", + true, + SettingEditor::Enum(PARTICLES), + ValueEncoding::Enum(PARTICLES), + ), + versioned_setting( + "clouds", + &["renderClouds", "clouds"], + version_changes::CLOUD_KEYS, + "video", + true, + SettingEditor::Enum(CLOUDS), + ValueEncoding::Clouds, + ), + ranged_setting( + "cloud_range", + &["cloudRange"], + "1.21.6", + "26.3", + "video", + false, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "entity_shadows", + &["entityShadows"], + "1.8.1", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + setting( + "view_bobbing", + &["bobView"], + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "vsync", + &["enableVsync"], + "1.3.1", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "fullscreen", + &["fullscreen"], + "1.3.1", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "exclusive_fullscreen", + &["exclusiveFullscreen"], + "26.1", + "26.3", + "video", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "mac_fullscreen_menu", + &["macFullscreenMenuVisibility"], + "26.3", + "26.3", + "video", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "max_framerate", + &["maxFps"], + "1.7.2", + "26.3", + "video", + true, + SettingEditor::Integer { + min: 10, + max: 260, + step: 10, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "legacy_framerate_limit", + &["fpsLimit"], + "1.0", + "1.6.4", + "video", + false, + SettingEditor::Enum(LEGACY_FPS_LIMIT), + ValueEncoding::Enum(LEGACY_FPS_LIMIT), + ), + ranged_setting( + "inactivity_framerate_limit", + &["inactivityFpsLimit"], + "1.21.2", + "26.3", + "video", + true, + SettingEditor::Enum(INACTIVITY_FPS_LIMIT), + ValueEncoding::QuotedEnum(INACTIVITY_FPS_LIMIT), + ), + ranged_setting( + "mipmap_levels", + &["mipmapLevels"], + "1.7.2", + "26.3", + "video", + false, + SettingEditor::Integer { + min: 0, + max: 4, + step: 1, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "biome_blend_radius", + &["biomeBlendRadius"], + "1.13", + "26.3", + "video", + true, + SettingEditor::Integer { + min: 0, + max: 7, + step: 1, + }, + ValueEncoding::Integer, + ), + ranged_setting( + "prioritize_chunk_updates", + &["prioritizeChunkUpdates"], + "1.18", + "26.3", + "video", + true, + SettingEditor::Enum(PRIORITIZE_CHUNK_UPDATES), + ValueEncoding::Enum(PRIORITIZE_CHUNK_UPDATES), + ), + ranged_setting( + "attack_indicator", + &["attackIndicator"], + "1.9", + "26.3", + "video", + true, + SettingEditor::Enum(ATTACK_INDICATOR), + ValueEncoding::Enum(ATTACK_INDICATOR), + ), + ranged_setting( + "reduced_debug_info", + &["reducedDebugInfo"], + "1.8", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "chunk_fade_time", + &["chunkSectionFadeInTime"], + "1.21.11", + "26.3", + "video", + false, + SettingEditor::UnboundedDecimal, + ValueEncoding::Decimal, + ), + ranged_setting( + "cutout_leaves", + &["cutoutLeaves"], + "1.21.11", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "improved_transparency", + &["improvedTransparency"], + "1.21.11", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "texture_filtering", + &["textureFiltering"], + "1.21.11", + "26.3", + "video", + false, + SettingEditor::Enum(TEXTURE_FILTERING), + ValueEncoding::Enum(TEXTURE_FILTERING), + ), + ranged_setting( + "anisotropy", + &["maxAnisotropyBit"], + "1.21.11", + "26.3", + "video", + false, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "vignette", + &["vignette"], + "1.21.11", + "26.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "weather_radius", + &["weatherRadius"], + "1.21.11", + "26.3", + "video", + false, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "advanced_opengl", + &["advancedOpengl"], + "1.0", + "1.7.10", + "video", + false, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "anaglyph_3d", + &["anaglyph3d"], + "1.0", + "1.12.2", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "anisotropic_filtering", + &["anisotropicFiltering"], + "1.7.2", + "1.7.10", + "video", + false, + SettingEditor::UnboundedInteger, + ValueEncoding::Integer, + ), + ranged_setting( + "alternate_blocks", + &["allowBlockAlternatives"], + "1.8", + "1.8.9", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "held_item_tooltips", + &["heldItemTooltips"], + "1.4.6", + "1.19.3", + "video", + true, + BOOL, + ValueEncoding::Bool, + ), + ranged_setting( + "use_vbo", + &["useVbo"], + "1.8", + "1.13.2", + "video", + false, + BOOL, + ValueEncoding::Bool, + ), +]; diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/fullscreen.rs b/packages/app-lib/src/api/instance/synced_options/game_options/fullscreen.rs new file mode 100644 index 0000000..aa5a3b4 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/fullscreen.rs @@ -0,0 +1,149 @@ +//! Keeps the app-wide fullscreen setting aligned with the shared Minecraft value. + +use super::CATALOG_REVISION; +use crate::state::{ + CanonicalValue, State, load_game_option_preferences, + load_game_options_sync_state, load_shared_game_options, +}; +use chrono::Utc; +use sqlx::{Sqlite, Transaction}; + +const FULLSCREEN_OPTION_ID: &str = "fullscreen"; + +pub(crate) async fn shared_fullscreen_value( + state: &State, +) -> crate::Result> { + let preferences = load_game_option_preferences(&state.pool).await?; + if !preferences + .get(FULLSCREEN_OPTION_ID) + .is_some_and(|preference| preference.enabled) + { + return Ok(None); + } + let values = load_shared_game_options(&state.pool).await?; + Ok(values.get(FULLSCREEN_OPTION_ID).and_then(|stored| { + if !stored.seeded { + return None; + } + match stored.value.as_ref() { + Some(CanonicalValue::Bool(value)) => Some(*value), + _ => None, + } + })) +} + +pub(crate) async fn update_shared_fullscreen_from_app( + state: &State, + value: bool, +) -> crate::Result { + let values = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let stored = values.get(FULLSCREEN_OPTION_ID); + let preference = preferences.get(FULLSCREEN_OPTION_ID); + let value_matches = stored.is_some_and(|stored| { + stored.seeded + && matches!( + stored.value.as_ref(), + Some(CanonicalValue::Bool(current)) if *current == value + ) + }); + let sync_enabled = preference.is_some_and(|preference| preference.enabled); + if !sync_enabled || value_matches { + return Ok(false); + } + + let option_revision = stored + .map(|stored| stored.revision) + .unwrap_or(0) + .max( + preference + .map(|preference| preference.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + let (canonical_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let next_canonical_revision = canonical_revision.saturating_add(1) as i64; + let canonical = CanonicalValue::Bool(value); + let value_json = serde_json::to_string(&canonical)?; + let canonical_type = canonical.type_name(); + let now = Utc::now().timestamp(); + let catalog_revision = CATALOG_REVISION as i64; + let kind = "vanilla"; + let raw_key: Option<&str> = None; + let value_codec = "catalog"; + let source_game_version: Option<&str> = None; + let mut tx = state.pool.begin().await?; + + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, 'app_editor', ?, NULL, ?) + ON CONFLICT(option_id) DO UPDATE SET + kind = excluded.kind, raw_key = excluded.raw_key, + canonical_type = excluded.canonical_type, + canonical_value_json = excluded.canonical_value_json, + value_codec = excluded.value_codec, seeded = 1, + revision = excluded.revision, origin = 'app_editor', + source_game_version = NULL, source_instance_id = NULL, + updated_at = excluded.updated_at", + ) + .bind(FULLSCREEN_OPTION_ID) + .bind(kind) + .bind(raw_key) + .bind(canonical_type) + .bind(value_json) + .bind(value_codec) + .bind(option_revision) + .bind(source_game_version) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + enabled = excluded.enabled, source = 'user', + revision = excluded.revision", + ) + .bind(FULLSCREEN_OPTION_ID) + .bind(true) + .bind("user") + .bind(option_revision) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO synced_game_option_state (singleton, revision, catalog_revision) + VALUES (1, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + revision = excluded.revision, + catalog_revision = excluded.catalog_revision", + ) + .bind(next_canonical_revision) + .bind(catalog_revision) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) +} + +pub(super) async fn update_app_fullscreen_setting( + tx: &mut Transaction<'_, Sqlite>, + value: &CanonicalValue, + sync_enabled: bool, +) -> crate::Result<()> { + if !sync_enabled { + return Ok(()); + } + let CanonicalValue::Bool(value) = value else { + return Ok(()); + }; + sqlx::query("UPDATE settings SET mc_force_fullscreen = ? WHERE id = 0") + .bind(*value) + .execute(&mut **tx) + .await?; + Ok(()) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/instance_support.rs b/packages/app-lib/src/api/instance/synced_options/game_options/instance_support.rs new file mode 100644 index 0000000..b17e0ea --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/instance_support.rs @@ -0,0 +1,498 @@ +//! Works out which instances can receive each shared setting. + +use super::super as synced_options; +use super::CATALOG_REVISION; +use super::api_types::*; +use super::catalog::*; +use super::options_file::{ + GameOptionsDocument, options_path, read_document, sha1_bytes, +}; +use crate::state::{ + CanonicalValue, InstanceMetadata, State, StoredOption, + game_options_sync_is_enabled, +}; +use std::collections::BTreeMap; + +/// The instance data needed to decide whether a setting can be synced. +#[derive(Clone)] +pub(super) struct ParticipatingInstance { + metadata: InstanceMetadata, + deferred: bool, + fullscreen_controlled: bool, + pub(super) document: Option, + inspection_error: Option, +} + +/// Loads every instance with game-settings sync turned on. +/// +/// An instance may not have an `options.txt` until its first launch. It still takes +/// part in sync, but its settings are marked as waiting for the file. +pub(super) async fn load_participating_instances( + state: &State, +) -> crate::Result> { + if !game_options_sync_is_enabled(&state.pool).await? { + return Ok(Vec::new()); + } + let instances = crate::state::list_instances(&state.pool).await?; + let mut participants = Vec::new(); + for metadata in instances { + if !metadata.synced_options.game_options { + continue; + } + let deferred = synced_options::sync_files_are_protected(&metadata) + || synced_options::pending::contains( + &metadata.instance.id, + crate::state::SyncedOption::GameOptions, + state, + ) + .await?; + let path = options_path(&metadata, state)?; + let (document, inspection_error) = if path.exists() { + match read_document(&path).await { + Ok((document, _)) => (Some(document), None), + Err(error) => { + tracing::warn!( + "Could not inspect game options compatibility for {}: {error}", + metadata.instance.id + ); + (None, Some(error.to_string())) + } + } + } else { + (None, None) + }; + participants.push(ParticipatingInstance { + fullscreen_controlled: metadata + .launch_overrides + .force_fullscreen + .is_some(), + metadata, + deferred, + document, + inspection_error, + }); + } + Ok(participants) +} + +pub(super) fn summary_revision( + canonical_revision: u64, + participants: &[ParticipatingInstance], +) -> String { + let mut summary = format!("{CATALOG_REVISION}:{canonical_revision}"); + let mut participants = participants.iter().collect::>(); + participants.sort_by(|left, right| { + left.metadata.instance.id.cmp(&right.metadata.instance.id) + }); + for participant in participants { + summary.push('|'); + summary.push_str(&participant.metadata.instance.id); + summary.push(':'); + summary + .push_str(&participant.metadata.applied_content_set.game_version); + summary.push(':'); + summary.push_str(if participant.deferred { + "deferred" + } else { + "ready" + }); + summary.push(':'); + summary.push_str(if participant.inspection_error.is_some() { + "degraded" + } else if participant.document.is_some() { + "file" + } else { + "missing" + }); + summary.push(':'); + summary.push_str(if participant.fullscreen_controlled { + "fullscreen-controlled" + } else { + "fullscreen-shared" + }); + } + sha1_bytes(summary.as_bytes()) +} +/// Counts which instances can receive a setting now, later, or not at all. +/// +/// The settings screen uses these results for its preview, and the file writer uses +/// the same checks when it saves. A setting shown as blocked therefore cannot be +/// written behind the user's back. +pub(super) fn describe_instance_support( + definition: Option<&SupportedSetting>, + stored: &StoredOption, + value: Option<&CanonicalValue>, + participants: &[ParticipatingInstance], +) -> GameOptionCompatibility { + let total = participants.len() as u32; + let mut will_receive = 0; + let mut write_now = 0; + let mut buckets: BTreeMap< + (String, String, String, String), + GameOptionCompatibilityBucket, + > = BTreeMap::new(); + + for participant in participants { + let version = participant + .metadata + .applied_content_set + .game_version + .clone(); + // Check launcher-controlled settings first. Finding the key in options.txt + // does not make a launcher-owned setting safe to overwrite. + let (status, key, mapping, reason) = if stored.option_id == "fullscreen" + && participant.fullscreen_controlled + { + ( + GameOptionCompatibilityStatus::Controlled, + None, + None, + Some(GameOptionCompatibilityReason::LauncherControlled), + ) + } else if definition.is_some() + && !supported_settings_cover_game_version(&version) + { + ( + GameOptionCompatibilityStatus::CatalogUncovered, + None, + None, + Some(GameOptionCompatibilityReason::CatalogUncovered), + ) + } else if participant.inspection_error.is_some() { + ( + GameOptionCompatibilityStatus::Degraded, + None, + None, + Some(GameOptionCompatibilityReason::InspectionFailed), + ) + } else { + match &participant.document { + None => { + // An instance that has not launched yet has no options.txt. If we know + // the key Minecraft will create, wait for the file instead of excluding it. + if definition.is_none() + && stored.raw_key.is_some() + && matches!(value, Some(CanonicalValue::ExternalRaw(_))) + { + ( + GameOptionCompatibilityStatus::WaitingForFile, + stored.raw_key.clone(), + Some(GameOptionMappingKind::Direct), + Some(GameOptionCompatibilityReason::WaitingForOptionsFile), + ) + } else if let Some((key, mapping)) = + definition.and_then(|definition| { + target_key_for_version(definition, &version).map( + |key| { + let mapping = physical_variant_for_version( + definition, &version, + ) + .map(|variant| variant.mapping) + .unwrap_or(GameOptionMappingKind::Direct); + (key, mapping) + }, + ) + }) + { + let representable = value.is_none_or(|value| { + definition.is_some_and(|definition| { + encode_value( + definition, key, value, &version, None, + ) + .is_some() + }) + }); + if representable { + ( + GameOptionCompatibilityStatus::WaitingForFile, + Some(key.to_string()), + Some(mapping), + Some(GameOptionCompatibilityReason::WaitingForOptionsFile), + ) + } else { + ( + GameOptionCompatibilityStatus::UnsupportedValue, + Some(key.to_string()), + None, + Some(GameOptionCompatibilityReason::UnsupportedValue), + ) + } + } else { + ( + GameOptionCompatibilityStatus::NotAvailable, + None, + None, + Some(GameOptionCompatibilityReason::MissingSetting), + ) + } + } + Some(document) => { + // Vanilla keys sometimes change between versions. Replace an old key with + // its newer name only when we know the value can be converted safely. + let key = target_physical_key( + definition, stored, document, &version, + ); + if let Some(key) = key { + let needs_alias_migration = + definition.is_some_and(|definition| { + alias_migration_needed( + definition, document, &key, + ) + }); + let representable = + value.is_none_or(|value| match definition { + Some(definition) => encode_value( + definition, + &key, + value, + &version, + document.value(&key), + ) + .is_some(), + None => matches!( + value, + CanonicalValue::ExternalRaw(_) + ), + }); + if !representable { + ( + GameOptionCompatibilityStatus::UnsupportedValue, + Some(key.to_string()), + None, + Some(GameOptionCompatibilityReason::UnsupportedValue), + ) + } else { + let mapped = if needs_alias_migration { + Some(GameOptionMappingKind::Migrated) + } else { + definition.and_then(|definition| { + physical_variant_for_present_key( + definition, &key, &version, + ) + .map(|variant| variant.mapping) + .or_else(|| { + (matches!( + definition.encoding, + ValueEncoding::KeyBinding + ) && document.value(&key).is_some_and( + |raw| { + split_key_binding(raw) + .0 + .parse::() + .is_ok() + }, + )) + .then_some( + GameOptionMappingKind::Migrated, + ) + }) + }) + }; + let legacy = definition.is_some_and(|definition| { + definition.keys.first().copied() + != Some(key.as_str()) + }); + ( + if participant.deferred { + GameOptionCompatibilityStatus::Deferred + } else { + GameOptionCompatibilityStatus::Ready + }, + Some(key.to_string()), + Some(mapped.unwrap_or(if legacy { + GameOptionMappingKind::Legacy + } else { + GameOptionMappingKind::Direct + })), + needs_alias_migration + .then_some(GameOptionCompatibilityReason::MigratesOnWrite), + ) + } + } else if let Some(definition) = + definition.filter(|definition| { + setting_available_for_version(definition, &version) + && definition + .keys + .iter() + .any(|key| document.value(key).is_some()) + }) + { + let eventual_key = + target_key_for_version(definition, &version) + .map(str::to_string); + let representable = + eventual_key.as_deref().is_some_and(|key| { + value.is_none_or(|value| { + encode_value( + definition, key, value, &version, None, + ) + .is_some() + }) + }); + if representable { + ( + GameOptionCompatibilityStatus::WaitingForBase, + eventual_key, + Some(GameOptionMappingKind::Migrated), + Some(GameOptionCompatibilityReason::WaitingForCompatibleBase), + ) + } else { + ( + GameOptionCompatibilityStatus::UnsupportedValue, + eventual_key, + None, + Some(GameOptionCompatibilityReason::UnsupportedValue), + ) + } + } else { + ( + GameOptionCompatibilityStatus::NotAvailable, + None, + None, + Some(GameOptionCompatibilityReason::MissingSetting), + ) + } + } + } + }; + + let receives = matches!( + status, + GameOptionCompatibilityStatus::Ready + | GameOptionCompatibilityStatus::Deferred + | GameOptionCompatibilityStatus::WaitingForFile + | GameOptionCompatibilityStatus::WaitingForBase + ); + if receives { + will_receive += 1; + } + if matches!(status, GameOptionCompatibilityStatus::Ready) { + write_now += 1; + } + let status_key = serde_json::to_string(&status).unwrap_or_default(); + let key_name = key.clone().unwrap_or_default(); + let mapping_key = mapping + .as_ref() + .and_then(|mapping| serde_json::to_string(mapping).ok()) + .unwrap_or_default(); + let reason_key = reason + .as_ref() + .and_then(|reason| serde_json::to_string(reason).ok()) + .unwrap_or_default(); + // Group instances with the same result to keep the response small. Keep their + // Minecraft versions so the UI can explain which versions are affected. + let bucket = buckets + .entry((status_key, key_name.clone(), mapping_key, reason_key)) + .or_insert_with(|| GameOptionCompatibilityBucket { + instance_count: 0, + write_keys: if matches!( + status, + GameOptionCompatibilityStatus::Ready + | GameOptionCompatibilityStatus::Deferred + ) { + key.clone().into_iter().collect() + } else { + Vec::new() + }, + eventual_keys: if matches!( + status, + GameOptionCompatibilityStatus::WaitingForFile + | GameOptionCompatibilityStatus::WaitingForBase + ) { + key.clone().into_iter().collect() + } else { + Vec::new() + }, + game_versions: vec![version.clone()], + status, + mapping, + reason, + }); + bucket.instance_count += 1; + if !bucket.game_versions.contains(&version) { + bucket.game_versions.push(version); + bucket.game_versions.sort(); + } + } + + let left_local = total.saturating_sub(will_receive); + GameOptionCompatibility { + total_participating: total, + will_receive, + write_now, + left_local, + buckets: buckets.into_values().collect(), + } +} + +/// Uses the local value only when every readable instance currently agrees on it. +pub(super) fn find_common_local_value( + definition: Option<&SupportedSetting>, + stored: &StoredOption, + participants: &[ParticipatingInstance], +) -> (Option, GameOptionValueState) { + let mut values = Vec::new(); + let mut invalid = false; + for participant in participants { + if stored.option_id == "fullscreen" && participant.fullscreen_controlled + { + continue; + } + if definition.is_some() + && !supported_settings_cover_game_version( + &participant.metadata.applied_content_set.game_version, + ) + { + continue; + } + let Some(document) = &participant.document else { + continue; + }; + let key = if let Some(definition) = definition { + observed_physical_key( + definition, + document, + &participant.metadata.applied_content_set.game_version, + ) + } else { + target_physical_key( + None, + stored, + document, + &participant.metadata.applied_content_set.game_version, + ) + }; + let Some(key) = key else { + continue; + }; + let Some(raw_value) = document.value(&key) else { + continue; + }; + let value = if let Some(definition) = definition { + decode_value(definition, &key, raw_value) + } else { + Some(CanonicalValue::ExternalRaw(raw_value.to_string())) + }; + if let Some(value) = + value.filter(|value| validate_file_value(definition, value).is_ok()) + { + values.push(value); + } else { + invalid = true; + } + } + if invalid && values.is_empty() { + return (None, GameOptionValueState::Invalid); + } + let Some(first) = values.first().cloned() else { + return (None, GameOptionValueState::Unset); + }; + if values + .iter() + .all(|value| canonical_values_equal(value, &first)) + { + (Some(first), GameOptionValueState::UniformLocal) + } else { + (None, GameOptionValueState::Mixed) + } +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/launch_overrides.rs b/packages/app-lib/src/api/instance/synced_options/game_options/launch_overrides.rs new file mode 100644 index 0000000..73ba5f9 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/launch_overrides.rs @@ -0,0 +1,76 @@ +//! Writes synced settings and launcher overrides before Minecraft starts. + +use super::options_file::{ + GameOptionsDocument, input_error, options_path, read_document, sha1_bytes, + validate_raw_key_value, +}; +use super::write_shared_settings::{ + apply_shared_settings_to_instance, remember_processed_sha, + sync_is_active_for_instance, +}; +use crate::state::{InstanceMetadata, State}; +use crate::util::io; +use std::collections::HashSet; + +pub(super) fn currently_launcher_owned_keys( + metadata: &InstanceMetadata, +) -> HashSet { + let mut keys = HashSet::new(); + if metadata.launch_overrides.force_fullscreen.is_some() { + keys.insert("fullscreen".to_string()); + } + keys +} + +/// Writes canonical settings to `options.txt` before launch. +pub async fn sync_before_launch(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| input_error("Unknown instance"))?; + if sync_is_active_for_instance(&metadata, &state).await? { + let _ = + apply_shared_settings_to_instance(&metadata, &state, false).await?; + } + Ok(()) +} + +/// Applies launcher settings, such as forced fullscreen, after shared settings. +/// +/// Stores the written file's hash so the watcher ignores the app's write. +pub async fn apply_launcher_overrides( + instance_id: &str, + overrides: &[(String, String)], +) -> crate::Result<()> { + if overrides.is_empty() { + return Ok(()); + } + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| input_error("Unknown instance"))?; + let path = options_path(&metadata, &state)?; + let (mut document, input_bytes) = if path.exists() { + read_document(&path).await? + } else { + ( + GameOptionsDocument::for_instance(&metadata, &state) + .await? + .unwrap_or_else(GameOptionsDocument::empty), + Vec::new(), + ) + }; + for (key, value) in overrides { + validate_raw_key_value(key, value)?; + document.set(key, value, true)?; + } + let output_bytes = document.serialize()?; + let output_sha1 = sha1_bytes(&output_bytes); + if output_bytes != input_bytes { + io::write(&path, output_bytes).await?; + } + remember_processed_sha(instance_id, &output_sha1, &state).await?; + Ok(()) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/local_settings_editor.rs b/packages/app-lib/src/api/instance/synced_options/game_options/local_settings_editor.rs new file mode 100644 index 0000000..2fda725 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/local_settings_editor.rs @@ -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 { + [ + "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, + HashSet, +)> { + 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, +) -> 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::>(); + 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 { + 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 { + 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 { + 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 { + 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() + }) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/archive.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/archive.rs new file mode 100644 index 0000000..3020cb0 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/archive.rs @@ -0,0 +1,272 @@ +use super::super::options_file::{input_error, sha1_bytes}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Cursor, Read, Seek}; +use std::path::Path; +use zip::ZipArchive; + +pub(super) const MAX_LANGUAGE_BYTES: usize = 4 * 1024 * 1024; +const MAX_ARCHIVE_ENTRIES: usize = 100_000; +const MAX_NESTED_BYTES: usize = 64 * 1024 * 1024; +const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024; +const MAX_NESTED_DEPTH: usize = 4; +pub(super) type Translations = BTreeMap; + +#[derive(Default, Serialize, Deserialize)] +pub(super) struct ArchiveIndex { + #[serde(default)] + pub version: u32, + pub bundles: Vec, +} + +#[derive(Default, Serialize, Deserialize)] +pub(super) struct LanguageBundle { + pub nested_path: String, + pub locales: BTreeMap, + #[serde(default)] + pub deprecated: DeprecatedTranslations, +} + +#[derive(Default, Serialize, Deserialize)] +pub(super) struct DeprecatedTranslations { + #[serde(default)] + removed: Vec, + #[serde(default)] + renamed: BTreeMap, +} + +impl DeprecatedTranslations { + pub fn apply(&self, translations: &mut Translations) { + for key in &self.removed { + translations.remove(key); + } + for (from, to) in &self.renamed { + if let Some(value) = translations.remove(from) { + translations.insert(to.clone(), value); + } else { + translations.remove(to); + } + } + } +} + +pub(super) fn parse_language( + bytes: &[u8], + legacy: bool, +) -> crate::Result { + if bytes.len() > MAX_LANGUAGE_BYTES { + return Err(input_error("Minecraft language file exceeds size limit")); + } + let values: Translations = if legacy { + String::from_utf8_lossy(bytes) + .lines() + .filter(|line| !line.starts_with('#')) + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect() + } else { + serde_json::from_slice::>(bytes)? + .into_iter() + .filter_map(|(key, value)| { + value.as_str().map(|s| (key, s.to_owned())) + }) + .collect() + }; + Ok(values + .into_iter() + .filter(|(key, value)| { + !key.is_empty() && key.len() <= 1024 && value.len() <= 32 * 1024 + }) + .collect()) +} + +pub(super) fn inspect( + path: &Path, + expected_hash: &str, +) -> crate::Result { + let mut file = + std::fs::File::open(path).map_err(crate::util::io::IOError::from)?; + let mut hasher = sha1_smol::Sha1::new(); + let mut buffer = vec![0; 64 * 1024].into_boxed_slice(); + loop { + let count = file + .read(&mut buffer) + .map_err(crate::util::io::IOError::from)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + if hasher.digest().to_string() != expected_hash { + return Err(input_error( + "Locale source changed before it could be indexed", + )); + } + file.rewind().map_err(crate::util::io::IOError::from)?; + let mut index = ArchiveIndex { + version: 2, + ..Default::default() + }; + let mut budget = MAX_TOTAL_BYTES; + let mut entries = MAX_ARCHIVE_ENTRIES; + inspect_archive(file, "", 0, &mut budget, &mut entries, &mut index)?; + Ok(index) +} + +fn read_entry( + archive: &mut ZipArchive, + name: &str, + limit: usize, + budget: &mut usize, +) -> crate::Result> { + let mut entry = archive + .by_name(name) + .map_err(|e| input_error(e.to_string()))?; + let limit = limit.min(*budget); + if entry.size() > limit as u64 { + return Err(input_error("Locale archive entry exceeds size limit")); + } + let mut bytes = Vec::new(); + entry + .by_ref() + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(crate::util::io::IOError::from)?; + if bytes.len() > limit { + return Err(input_error("Locale archive entry exceeds size limit")); + } + *budget -= bytes.len(); + Ok(bytes) +} + +fn inspect_archive( + reader: R, + nested_path: &str, + depth: usize, + budget: &mut usize, + entries: &mut usize, + index: &mut ArchiveIndex, +) -> crate::Result<()> { + let mut archive = + ZipArchive::new(reader).map_err(|e| input_error(e.to_string()))?; + if archive.len() > *entries { + return Err(input_error("Too many entries in locale archive")); + } + *entries -= archive.len(); + let names: BTreeSet = + archive.file_names().map(str::to_owned).collect(); + let mut bundle = LanguageBundle { + nested_path: nested_path.to_owned(), + ..Default::default() + }; + for name in &names { + let parts: Vec<_> = name.split('/').collect(); + let (namespace, filename) = match parts.as_slice() { + ["assets", namespace, "lang", filename] => (*namespace, *filename), + ["lang", filename] => ("minecraft", *filename), + _ => continue, + }; + if filename == "deprecated.json" { + if namespace == "minecraft" { + let bytes = + read_entry(&mut archive, name, MAX_LANGUAGE_BYTES, budget)?; + bundle.deprecated = serde_json::from_slice(&bytes)?; + } + continue; + } + let legacy = filename.ends_with(".lang"); + let Some(locale) = + filename.strip_suffix(if legacy { ".lang" } else { ".json" }) + else { + continue; + }; + if !valid_locale(locale) { + continue; + } + let bytes = read_entry(&mut archive, name, MAX_LANGUAGE_BYTES, budget)?; + match parse_language(&bytes, legacy) { + Ok(values) => bundle + .locales + .entry(locale.to_ascii_lowercase()) + .or_default() + .extend(values), + Err(error) => { + tracing::debug!(%name, %error, "Skipping malformed mod language file") + } + } + } + index.bundles.push(bundle); + let mut nested = BTreeSet::new(); + for manifest in [ + "fabric.mod.json", + "quilt.mod.json", + "META-INF/jarjar/metadata.json", + ] { + if !names.contains(manifest) { + continue; + } + let bytes = + read_entry(&mut archive, manifest, MAX_LANGUAGE_BYTES, budget)?; + let Ok(value) = serde_json::from_slice::(&bytes) + else { + continue; + }; + let jars = value + .get("jars") + .or_else(|| value.pointer("/quilt_loader/jars")); + for jar in jars.and_then(|v| v.as_array()).into_iter().flatten() { + if let Some(path) = jar + .as_str() + .or_else(|| jar.get("file").and_then(|v| v.as_str())) + .or_else(|| jar.get("path").and_then(|v| v.as_str())) + { + nested.insert(path.to_owned()); + } + } + } + if !nested.is_empty() && depth >= MAX_NESTED_DEPTH { + return Err(input_error("Locale archive nesting exceeds limit")); + } + for name in nested { + if !names.contains(&name) || !name.ends_with(".jar") { + continue; + } + let bytes = read_entry(&mut archive, &name, MAX_NESTED_BYTES, budget)?; + let path = if nested_path.is_empty() { + name + } else { + format!("{nested_path}!{name}") + }; + inspect_archive( + Cursor::new(bytes), + &path, + depth + 1, + budget, + entries, + index, + )?; + } + Ok(()) +} + +pub(super) fn valid_locale(locale: &str) -> bool { + !locale.is_empty() + && locale.len() <= 32 + && locale + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_') +} + +pub(super) fn valid_hash(hash: &str) -> bool { + hash.len() == 40 && hash.bytes().all(|c| c.is_ascii_hexdigit()) +} + +pub(super) fn checked_bytes( + bytes: Vec, + hash: &str, +) -> crate::Result> { + if sha1_bytes(&bytes) != hash { + return Err(input_error("Minecraft locale resource checksum mismatch")); + } + Ok(bytes) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/mod.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/mod.rs new file mode 100644 index 0000000..50eda36 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/mod.rs @@ -0,0 +1,581 @@ +//! Caches mod translations and remembers which JAR supplied each setting. + +mod archive; +mod origins; +mod sources; +mod storage; + +use super::catalog::{is_never_sync_key, setting_by_file_key}; +use super::options_file::{ + GameOptionsDocument, input_error, options_path, read_document, +}; +use crate::state::{InstanceMetadata, State}; +use archive::{ArchiveIndex, Translations}; +use serde::{Deserialize, Serialize}; +use sources::{ArchiveSource, Snapshot}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tokio::sync::Notify; + +#[derive(Default)] +pub(crate) struct GameLocaleIndexer { + notify: Notify, + started: AtomicBool, +} + +#[derive(Clone, Serialize, Deserialize)] +struct Observation { + snapshot: String, + raw_key: String, +} + +#[derive(Clone, Serialize, Deserialize)] +struct Origin { + instance_id: String, + game_version: String, + /// Rejects origins that older versions resolved from Minecraft instead of a mod. + #[serde(default, rename = "game_jar_hash", skip_serializing)] + legacy_game_jar_hash: Option, + archive: ArchiveSource, + nested_path: String, + translation_key: String, + choices: BTreeMap, +} + +#[derive(Default, Serialize)] +pub struct GameSettingLocaleLabels { + pub settings: BTreeMap, +} + +#[derive(Serialize)] +pub struct GameSettingLocaleLabel { + pub label: String, + pub choices: BTreeMap, + pub source: Option, +} + +#[derive(Serialize)] +pub struct GameSettingSource { + pub instance_id: String, + pub file_name: String, + pub file_path: String, + pub project: Option, +} + +#[derive(Serialize)] +pub struct GameSettingSourceProject { + pub id: String, + pub title: String, + pub icon_url: Option, +} + +struct Candidate { + snapshot_id: String, + snapshot: Snapshot, + keys: BTreeMap, +} + +pub(crate) fn queue_game_locale_index() { + if let Some(state) = State::get_if_initialized() { + tracing::info!( + started = state.game_locale_indexer.started.load(Ordering::Acquire), + "Game setting locales: indexing queued" + ); + state.game_locale_indexer.notify.notify_one(); + } else { + tracing::warn!( + "Game setting locales: cannot queue indexing before state initialization" + ); + } +} + +pub(crate) fn start_game_locale_indexer(state: Arc) { + if state + .game_locale_indexer + .started + .swap(true, Ordering::AcqRel) + { + return; + } + tracing::info!("Game setting locales: indexer started"); + state.game_locale_indexer.notify.notify_one(); + tokio::spawn(async move { + loop { + state.game_locale_indexer.notify.notified().await; + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let started = std::time::Instant::now(); + if let Err(error) = index_installed_sources(&state).await { + tracing::warn!(%error, "Game setting locales: indexing failed"); + } + tracing::info!( + elapsed_ms = started.elapsed().as_millis(), + "Game setting locales: indexing pass finished" + ); + } + }); +} + +fn mod_translation_key(raw_key: &str) -> Option<&str> { + if setting_by_file_key(raw_key).is_some() || is_never_sync_key(raw_key) { + return None; + } + raw_key.strip_prefix("key_") +} + +fn document_keys(document: &GameOptionsDocument) -> BTreeMap { + document + .effective_entries() + .keys() + .filter(|key| mod_translation_key(key).is_some()) + .map(|key| (format!("external:{key}"), (*key).to_owned())) + .collect() +} + +pub(super) fn has_localizable_mod_settings( + document: &GameOptionsDocument, +) -> bool { + document + .effective_entries() + .keys() + .any(|key| mod_translation_key(key).is_some()) +} + +/// Records mod JAR hashes before saving new options. +pub(super) async fn capture_observation( + metadata: &InstanceMetadata, + state: &State, +) -> Option { + match sources::snapshot_instance(metadata, state).await { + Ok(id) => Some(id), + Err(error) => { + tracing::warn!(%error, instance_id = metadata.instance.id, + "Game setting locales: snapshot capture failed"); + None + } + } +} + +pub(super) async fn record_observations( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + metadata: &InstanceMetadata, + document: &GameOptionsDocument, + snapshot: Option<&str>, +) { + let Some(snapshot) = snapshot else { + return; + }; + for (id, raw_key) in document_keys(document) { + let observation = Observation { + snapshot: snapshot.to_owned(), + raw_key, + }; + if let Err(error) = storage::observe( + tx, + "", + &id, + &metadata.instance.id, + &metadata.applied_content_set.game_version, + &observation, + ) + .await + { + tracing::warn!(%error, "Could not record game-setting translation provenance"); + } + } +} + +async fn cached_archive( + state: &State, + source: &ArchiveSource, + cache: &mut HashMap>, +) -> crate::Result> { + if let Some(index) = cache.get(&source.hash) { + return Ok(index.clone()); + } + let index = Arc::new(sources::archive_index(state, source).await?); + if cache.len() >= 8 { + cache.clear(); + } + cache.insert(source.hash.clone(), index.clone()); + Ok(index) +} + +#[tracing::instrument(skip_all, err)] +async fn index_installed_sources(state: &State) -> crate::Result<()> { + let mut instances = crate::state::list_instances(&state.pool).await?; + tracing::info!( + instances = instances.len(), + "Game setting locales: indexing installed instances" + ); + instances.sort_by(|a, b| a.instance.id.cmp(&b.instance.id)); + let mut candidates = Vec::new(); + let mut resolver = origins::OriginResolver::default(); + for metadata in instances { + if super::super::sync_files_are_protected(&metadata) { + tracing::info!( + instance_id = metadata.instance.id, + "Game setting locales: skipping protected instance" + ); + continue; + } + let path = match options_path(&metadata, state) { + Ok(path) => path, + Err(error) => { + tracing::warn!(%error, instance_id = metadata.instance.id, "Game setting locales: options path resolution failed"); + continue; + } + }; + let document = match read_document(&path).await { + Ok((document, _)) => document, + Err(error) => { + tracing::warn!(%error, instance_id = metadata.instance.id, "Game setting locales: options file read failed"); + continue; + } + }; + let keys = document_keys(&document); + if keys.is_empty() { + continue; + } + let Some(snapshot_id) = capture_observation(&metadata, state).await + else { + continue; + }; + let snapshot = match sources::load_snapshot(state, &snapshot_id).await { + Ok(snapshot) => snapshot, + Err(error) => { + tracing::warn!(%error, instance_id = metadata.instance.id, %snapshot_id, + "Could not load game-setting locale snapshot"); + continue; + } + }; + tracing::info!(instance_id = metadata.instance.id, %snapshot_id, game_version = snapshot.game_version, + mods = snapshot.mods.len(), + "Game setting locales: snapshot loaded"); + resolver + .index_snapshot(state, &snapshot_id, snapshot.clone()) + .await?; + let mut tx = state.pool.begin().await?; + for (id, key) in &keys { + let observation = Observation { + snapshot: snapshot_id.clone(), + raw_key: key.clone(), + }; + storage::observe( + &mut tx, + &metadata.instance.id, + id, + &metadata.instance.id, + &snapshot.game_version, + &observation, + ) + .await?; + } + tx.commit().await?; + tracing::info!( + instance_id = metadata.instance.id, + options = keys.len(), + "Game setting locales: observations recorded" + ); + candidates.push(Candidate { + snapshot_id, + snapshot, + keys, + }); + } + let rows = storage::load(&state.pool, None).await?; + tracing::info!( + candidates = candidates.len(), + rows = rows.len(), + resolved = rows.iter().filter(|row| row.origin.is_some()).count(), + "Game setting locales: resolving translation origins" + ); + let mut pinned = 0; + let mut unresolved = 0; + for row in rows { + if row.origin.is_some() { + continue; + } + let mut observations = Vec::new(); + if let Some(observation) = &row.observation { + observations.push(observation.clone()); + } else { + let mut matching: Vec<_> = candidates + .iter() + .filter(|c| { + (row.scope.is_empty() + || row.scope == c.snapshot.instance_id) + && c.keys.contains_key(&row.option_id) + && (row.backfilled + || row + .source_instance_id + .as_ref() + .is_none_or(|id| *id == c.snapshot.instance_id) + && row.source_game_version.as_ref().is_none_or( + |v| *v == c.snapshot.game_version, + )) + }) + .collect(); + matching.sort_by_key(|c| { + ( + row.source_instance_id.as_deref() + != Some(c.snapshot.instance_id.as_str()), + row.source_game_version.as_deref() + != Some(c.snapshot.game_version.as_str()), + c.snapshot.instance_id.clone(), + ) + }); + observations.extend(matching.into_iter().map(|c| Observation { + snapshot: c.snapshot_id.clone(), + raw_key: c.keys[&row.option_id].clone(), + })); + } + let mut resolved = false; + let observation_count = observations.len(); + for observation in observations { + match resolver.resolve(state, &observation).await { + Ok(Some(origin)) => { + storage::pin(&state.pool, &row, &origin).await?; + resolved = true; + pinned += 1; + break; + } + Ok(None) => {} + Err(error) => { + tracing::warn!(%error, scope = row.scope, option_id = row.option_id, + snapshot_id = observation.snapshot, "Game setting locales: origin resolution failed") + } + } + } + if !resolved { + tracing::debug!( + scope = row.scope, + option_id = row.option_id, + observation_count, + backfilled = row.backfilled, + "Game setting locales: no matching translation origin" + ); + unresolved += 1; + } + } + tracing::info!( + pinned, + unresolved, + "Game setting locales: origin resolution finished" + ); + Ok(()) +} + +fn is_plain_label(value: &str) -> bool { + !value.trim().is_empty() && !value.contains('%') && !value.contains('§') +} + +fn plain_label(value: &str) -> Option { + is_plain_label(value).then(|| value.to_owned()) +} + +fn minecraft_locale(locale: &str) -> String { + match locale { + "es-419" => "es_mx".to_owned(), + "fil-PH" => "tl_ph".to_owned(), + "ms-MY" => "ms_my".to_owned(), + "zh-Hans" => "zh_cn".to_owned(), + "zh-Hant" => "zh_tw".to_owned(), + _ => locale.replace('-', "_").to_ascii_lowercase(), + } +} + +/// Loads translations for the settings shown in the modal. +#[tracing::instrument(skip_all, fields(instance_id, locale, requested = option_ids.len(), refresh_sources), err)] +pub async fn get_game_setting_locale_labels( + instance_id: Option<&str>, + locale: &str, + option_ids: Vec, + refresh_sources: bool, +) -> crate::Result { + tracing::info!("Game setting locales: label request received"); + if option_ids.len() > 16_384 { + return Err(input_error("Too many requested game-setting labels")); + } + let requested: std::collections::HashSet<_> = option_ids + .into_iter() + .filter(|id| { + id.strip_prefix("external:") + .and_then(mod_translation_key) + .is_some() + }) + .collect(); + if requested.is_empty() { + return Ok(GameSettingLocaleLabels::default()); + } + let locale = minecraft_locale(locale); + if !archive::valid_locale(&locale) { + return Err(input_error("Invalid Minecraft locale")); + } + let state = State::get().await?; + if refresh_sources { + queue_game_locale_index(); + } + let scope = instance_id.unwrap_or(""); + let mut rows = storage::load(&state.pool, Some(scope)).await?; + tracing::info!( + scope, + rows = rows.len(), + resolved = rows.iter().filter(|row| row.origin.is_some()).count(), + "Game setting locales: scoped origins loaded" + ); + if scope.is_empty() { + let mut sourced: std::collections::HashSet<_> = + rows.iter().map(|row| row.option_id.clone()).collect(); + // Unsaved global options have no provenance row; use an indexed local label until saved. + for row in storage::load(&state.pool, None).await? { + if !row.scope.is_empty() + && requested.contains(&row.option_id) + && row.origin.is_some() + && sourced.insert(row.option_id.clone()) + { + rows.push(row); + } + } + } + tracing::info!( + rows = rows.len(), + resolved = rows.iter().filter(|row| row.origin.is_some()).count(), + "Game setting locales: origins selected including fallbacks" + ); + let hashes: std::collections::HashSet<_> = rows + .iter() + .filter(|row| requested.contains(&row.option_id)) + .filter_map(|row| row.origin.as_ref()) + .filter(|origin| { + origin.legacy_game_jar_hash.as_deref() + != Some(origin.archive.hash.as_str()) + }) + .map(|origin| origin.archive.hash.as_str()) + .collect(); + let projects = sources::mod_projects(&state, &hashes.into_iter().collect::>()).await + .unwrap_or_else(|error| { + tracing::warn!(%error, "Game setting locales: mod project metadata unavailable"); + HashMap::new() + }); + let mut result = GameSettingLocaleLabels::default(); + let mut archives = HashMap::new(); + let mut dictionaries: HashMap = HashMap::new(); + let mut missing_origins = Vec::new(); + for row in rows { + if !requested.contains(&row.option_id) { + continue; + } + let Some(origin) = row.origin else { + missing_origins.push(row.option_id); + continue; + }; + if origin.legacy_game_jar_hash.as_deref() + == Some(origin.archive.hash.as_str()) + { + continue; + } + let dictionary_id = + format!("{}:{}", origin.archive.hash, origin.nested_path); + if !dictionaries.contains_key(&dictionary_id) { + let index = match cached_archive( + &state, + &origin.archive, + &mut archives, + ) + .await + { + Ok(index) => index, + Err(error) => { + tracing::warn!(%error, option_id = row.option_id, hash = origin.archive.hash, + "Game setting locales: label archive unavailable"); + continue; + } + }; + let Some(bundle) = index + .bundles + .iter() + .find(|b| b.nested_path == origin.nested_path) + else { + tracing::warn!( + option_id = row.option_id, + nested_path = origin.nested_path, + "Game setting locales: source bundle missing" + ); + continue; + }; + let mut translations = + bundle.locales.get("en_us").cloned().unwrap_or_default(); + if let Some(selected) = bundle.locales.get(&locale) { + translations.extend(selected.clone()); + } + bundle.deprecated.apply(&mut translations); + tracing::info!(%dictionary_id, %locale, translations = translations.len(), + "Game setting locales: dictionary loaded"); + dictionaries.insert(dictionary_id.clone(), translations); + } + let translations = &dictionaries[&dictionary_id]; + let label = translations + .get(&origin.translation_key) + .and_then(|s| plain_label(s)); + if let Some(label) = label { + let choices = origin + .choices + .iter() + .filter_map(|(value, key)| { + translations + .get(key) + .and_then(|s| plain_label(s)) + .map(|label| (value.clone(), label)) + }) + .collect(); + let source = origin + .archive + .path + .file_name() + .and_then(|name| name.to_str()) + .map(|file_name| GameSettingSource { + instance_id: origin.instance_id.clone(), + file_name: file_name.to_owned(), + file_path: format!("mods/{file_name}"), + project: projects.get(&origin.archive.hash).map( + |project| GameSettingSourceProject { + id: project.id.clone(), + title: project.title.clone(), + icon_url: project.icon_url.clone(), + }, + ), + }); + result.settings.insert( + row.option_id, + GameSettingLocaleLabel { + label, + choices, + source, + }, + ); + } else { + tracing::warn!( + option_id = row.option_id, + key = origin.translation_key, + key_present = + translations.contains_key(&origin.translation_key), + "Game setting locales: translation missing or rejected as a label" + ); + } + } + let mut missing: Vec<_> = requested + .iter() + .filter(|id| !result.settings.contains_key(*id)) + .collect(); + missing.sort(); + tracing::info!( + returned = result.settings.len(), + ?missing, + ?missing_origins, + "Game setting locales: label request completed" + ); + Ok(result) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/origins.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/origins.rs new file mode 100644 index 0000000..67cffd9 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/origins.rs @@ -0,0 +1,144 @@ +use super::sources::{self, ArchiveSource, Snapshot}; +use super::{Observation, Origin, is_plain_label, mod_translation_key}; +use crate::state::State; +use crate::util::io; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +type ArchiveKeys = BTreeMap>; + +struct SnapshotIndex { + snapshot: Snapshot, + keys: BTreeMap)>, +} + +#[derive(Default)] +pub(super) struct OriginResolver { + archives: HashMap>, + snapshots: HashMap, +} + +impl OriginResolver { + pub(super) async fn index_snapshot( + &mut self, + state: &State, + id: &str, + snapshot: Snapshot, + ) -> crate::Result<()> { + if self.snapshots.contains_key(id) { + return Ok(()); + } + let mut archives = Vec::new(); + for (source_index, source) in snapshot.mods.iter().enumerate() { + let archive = if let Some(keys) = self.archives.get(&source.hash) { + Arc::clone(keys) + } else { + match load_archive_keys(state, source).await { + Ok(keys) => { + let keys = Arc::new(keys); + self.archives + .insert(source.hash.clone(), Arc::clone(&keys)); + keys + } + Err(error) => { + tracing::warn!(hash = source.hash, %error, + "Game setting locales: origin archive unavailable"); + continue; + } + } + }; + archives.push((source_index, archive)); + } + let index = tokio::task::spawn_blocking(move || { + let mut keys = BTreeMap::new(); + for (source_index, archive) in archives { + for (key, nested_path) in archive.iter() { + keys.entry(key.clone()).or_insert_with(|| { + (source_index, Arc::clone(nested_path)) + }); + } + } + SnapshotIndex { snapshot, keys } + }) + .await + .map_err(|error| super::input_error(error.to_string()))?; + self.snapshots.insert(id.to_owned(), index); + Ok(()) + } + + pub(super) async fn resolve( + &mut self, + state: &State, + observation: &Observation, + ) -> crate::Result> { + let Some(key) = mod_translation_key(&observation.raw_key) else { + return Ok(None); + }; + if !self.snapshots.contains_key(&observation.snapshot) { + let snapshot = + sources::load_snapshot(state, &observation.snapshot).await?; + self.index_snapshot(state, &observation.snapshot, snapshot) + .await?; + } + let index = &self.snapshots[&observation.snapshot]; + Ok(index + .keys + .get(key) + .map(|(source_index, nested_path)| Origin { + instance_id: index.snapshot.instance_id.clone(), + game_version: index.snapshot.game_version.clone(), + legacy_game_jar_hash: None, + archive: index.snapshot.mods[*source_index].clone(), + nested_path: nested_path.to_string(), + translation_key: key.to_owned(), + choices: BTreeMap::new(), + })) + } +} + +/// A complete key index also caches misses until the archive hash changes. +async fn load_archive_keys( + state: &State, + source: &ArchiveSource, +) -> crate::Result { + if !super::archive::valid_hash(&source.hash) { + return Err(super::input_error("Invalid locale archive hash")); + } + let path = sources::root(state) + .join("origin-keys-v1") + .join(format!("{}.json", source.hash)); + if let Ok(bytes) = io::read(&path).await { + let cached = tokio::task::spawn_blocking(move || { + serde_json::from_slice::(&bytes) + }) + .await + .map_err(|error| super::input_error(error.to_string()))?; + if let Ok(keys) = cached { + return Ok(keys); + } + } + let index = sources::archive_index(state, source).await?; + let keys = tokio::task::spawn_blocking(move || { + let mut keys = ArchiveKeys::new(); + for mut bundle in index.bundles { + let Some(mut english) = bundle.locales.remove("en_us") else { + continue; + }; + bundle.deprecated.apply(&mut english); + let nested_path: Arc = bundle.nested_path.into(); + for (key, value) in english { + if is_plain_label(&value) { + keys.entry(key).or_insert_with(|| Arc::clone(&nested_path)); + } + } + } + keys + }) + .await + .map_err(|error| super::input_error(error.to_string()))?; + if let Err(error) = sources::write_json(&path, &keys).await { + tracing::warn!(hash = source.hash, %error, + "Game setting locales: could not cache origin keys"); + } + Ok(keys) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/sources.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/sources.rs new file mode 100644 index 0000000..280596a --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/sources.rs @@ -0,0 +1,207 @@ +use super::super::options_file::{input_error, sha1_bytes}; +use super::archive::{self, ArchiveIndex}; +use crate::state::ModrinthProjectId; +use crate::state::{CachedEntry, InstanceMetadata, Project, State}; +use crate::util::{fetch, io}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct ArchiveSource { + pub hash: String, + pub path: PathBuf, +} + +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct Snapshot { + pub instance_id: String, + pub game_version: String, + pub mods: Vec, +} + +pub(super) async fn mod_projects( + state: &State, + hashes: &[&str], +) -> crate::Result> { + let files = CachedEntry::get_file_many( + hashes, + None, + &state.pool, + &state.api_semaphore, + ) + .await?; + let project_ids: std::collections::HashSet<_> = files + .iter() + .filter_map(|file| { + ModrinthProjectId::new(file.project_id.as_str().to_string()).ok() + }) + .collect(); + let projects = CachedEntry::get_project_many( + &project_ids.into_iter().collect::>(), + None, + &state.pool, + &state.api_semaphore, + ) + .await?; + let projects: HashMap<_, _> = projects + .into_iter() + .map(|project| (project.id.clone(), project)) + .collect(); + Ok(files + .into_iter() + .filter_map(|file| { + projects + .get(&file.project_id) + .cloned() + .map(|project| (file.hash, project)) + }) + .collect()) +} + +pub(super) fn root(state: &State) -> PathBuf { + state + .directories + .metadata_dir() + .join("game-locales") + .join("v1") +} + +pub(super) async fn write_json( + path: &std::path::Path, + data: &T, +) -> crate::Result<()> { + let parent = path + .parent() + .ok_or_else(|| input_error("Missing locale cache directory"))?; + io::create_dir_all(parent).await?; + io::write(path, serde_json::to_vec(data)?).await?; + Ok(()) +} + +pub(super) async fn snapshot_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let version = &metadata.applied_content_set.game_version; + let instance_dir = + crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + )?; + let direct_linked = metadata.instance.is_direct_linked(); + let cache_key_path = if direct_linked { + instance_dir.to_string_lossy().into_owned() + } else { + metadata.instance.path.clone() + }; + let scanned = + crate::state::instances::adapters::filesystem::scan_content_files_from( + &instance_dir, + &cache_key_path, + )?; + let scanned: Vec<_> = scanned + .into_iter() + .filter(|f| f.enabled && f.relative_path.starts_with("mods/")) + .collect(); + let keys: Vec<_> = + scanned.iter().map(|f| f.hash_cache_key.as_str()).collect(); + let hashes = CachedEntry::get_file_hash_many( + &keys, + None, + &state.pool, + &state.api_semaphore, + ) + .await?; + let by_path: BTreeMap<_, _> = + hashes.into_iter().map(|h| (h.path, h.hash)).collect(); + let mut mods = Vec::new(); + for file in scanned { + let path = instance_dir.join(&file.relative_path); + let key = format!("{cache_key_path}/{}", file.relative_path); + let hash = match by_path.get(&key) { + Some(hash) => hash.clone(), + None => fetch::sha1_file_async(&path).await?.1, + }; + mods.push(ArchiveSource { hash, path }); + } + mods.sort_by(|a, b| a.path.cmp(&b.path)); + let snapshot = Snapshot { + instance_id: metadata.instance.id.clone(), + game_version: version.clone(), + mods, + }; + let bytes = serde_json::to_vec(&snapshot)?; + let id = sha1_bytes(&bytes); + let path = root(state).join("snapshots").join(format!("{id}.json")); + if !io::read(&path).await.is_ok_and(|cached| cached == bytes) { + tracing::info!( + snapshot_id = id, + instance_id = metadata.instance.id, + "Game setting locales: writing missing or invalid snapshot cache" + ); + write_json(&path, &snapshot).await?; + } + Ok(id) +} + +#[tracing::instrument(skip_all, fields(snapshot_id = id), err)] +pub(super) async fn load_snapshot( + state: &State, + id: &str, +) -> crate::Result { + if !archive::valid_hash(id) { + return Err(input_error("Invalid locale snapshot hash")); + } + let bytes = + io::read(root(state).join("snapshots").join(format!("{id}.json"))) + .await?; + archive::checked_bytes(bytes.clone(), id).inspect_err(|error| { + tracing::warn!(%error, expected_hash = id, actual_hash = sha1_bytes(&bytes), + "Game setting locales: snapshot checksum mismatch"); + })?; + Ok(serde_json::from_slice(&bytes)?) +} + +pub(super) async fn archive_index( + state: &State, + source: &ArchiveSource, +) -> crate::Result { + if !archive::valid_hash(&source.hash) { + return Err(input_error("Invalid locale archive hash")); + } + let path = root(state) + .join("archives") + .join(format!("{}.json", source.hash)); + if let Ok(bytes) = io::read(&path).await { + let source_path = source.path.clone(); + let cached = tokio::task::spawn_blocking(move || { + serde_json::from_slice::(&bytes) + .ok() + .filter(|index| index.version >= 2 || !source_path.exists()) + }) + .await + .map_err(|error| input_error(error.to_string()))?; + if let Some(index) = cached { + tracing::debug!( + hash = source.hash, + version = index.version, + bundles = index.bundles.len(), + "Game setting locales: archive cache hit" + ); + return Ok(index); + } + } + tracing::info!( + hash = source.hash, + "Game setting locales: rebuilding archive cache" + ); + let source = source.clone(); + let index = tokio::task::spawn_blocking(move || { + archive::inspect(&source.path, &source.hash) + }) + .await + .map_err(|e| input_error(e.to_string()))??; + write_json(&path, &index).await?; + Ok(index) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/locales/storage.rs b/packages/app-lib/src/api/instance/synced_options/game_options/locales/storage.rs new file mode 100644 index 0000000..7b91eee --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/locales/storage.rs @@ -0,0 +1,132 @@ +use super::{Observation, Origin}; +use sqlx::{Sqlite, SqlitePool, Transaction}; + +pub(super) struct OriginRow { + pub scope: String, + pub option_id: String, + pub source_instance_id: Option, + pub source_game_version: Option, + pub backfilled: bool, + pub observation: Option, + pub origin: Option, +} + +#[tracing::instrument(skip_all, fields(scope), err)] +pub(super) async fn load( + pool: &SqlitePool, + scope: Option<&str>, +) -> crate::Result> { + let rows = sqlx::query( + "SELECT scope, option_id, source_instance_id, source_game_version, + backfilled, observation_json, origin_json + FROM game_option_locale_origins + WHERE (? IS NULL OR scope = ?) + ORDER BY scope, option_id", + ) + .bind(scope) + .bind(scope) + .fetch_all(pool) + .await?; + use sqlx::Row; + rows.into_iter() + .filter(|row| { + let option_id: String = row.get("option_id"); + option_id + .strip_prefix("external:") + .and_then(super::mod_translation_key) + .is_some() + }) + .map(|row| { + let scope: String = row.get("scope"); + let option_id: String = row.get("option_id"); + let source_instance_id: Option = row.get("source_instance_id"); + let source_game_version: Option = row.get("source_game_version"); + let backfilled: bool = row.get("backfilled"); + let observation_json: Option = row.get("observation_json"); + let origin_json: Option = row.get("origin_json"); + let observation = observation_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .inspect_err(|error| tracing::warn!(%error, scope = scope, option_id = option_id, + "Game setting locales: stored observation could not be decoded"))?; + let origin = origin_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .inspect_err(|error| tracing::warn!(%error, scope = scope, option_id = option_id, + "Game setting locales: stored origin could not be decoded"))?; + Ok(OriginRow { + scope, + option_id, + source_instance_id, + source_game_version, + backfilled, + observation, + origin, + }) + }) + .collect() +} + +#[tracing::instrument( + skip_all, + fields(scope, option_id, instance_id, game_version), + err +)] +pub(super) async fn observe( + tx: &mut Transaction<'_, Sqlite>, + scope: &str, + option_id: &str, + instance_id: &str, + game_version: &str, + observation: &Observation, +) -> crate::Result<()> { + let json = serde_json::to_string(observation)?; + sqlx::query( + "INSERT INTO game_option_locale_origins + (scope, option_id, source_instance_id, source_game_version, observation_json) + SELECT ?, ?, ?, ?, ? + WHERE ? <> '' OR EXISTS (SELECT 1 FROM synced_game_option_values WHERE option_id = ?) + ON CONFLICT(scope, option_id) DO UPDATE SET observation_json = excluded.observation_json + WHERE game_option_locale_origins.observation_json IS NULL + AND game_option_locale_origins.origin_json IS NULL + AND game_option_locale_origins.backfilled = 0 + AND game_option_locale_origins.source_instance_id = excluded.source_instance_id + AND game_option_locale_origins.source_game_version = excluded.source_game_version", + ) + .bind(scope) + .bind(option_id) + .bind(instance_id) + .bind(game_version) + .bind(json) + .bind(scope) + .bind(option_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +#[tracing::instrument(skip_all, fields(scope = row.scope, option_id = row.option_id), err)] +pub(super) async fn pin( + pool: &SqlitePool, + row: &OriginRow, + origin: &Origin, +) -> crate::Result<()> { + let json = serde_json::to_string(origin)?; + let result = sqlx::query( + "UPDATE game_option_locale_origins SET origin_json = ? + WHERE scope = ? AND option_id = ? AND origin_json IS NULL", + ) + .bind(json) + .bind(&row.scope) + .bind(&row.option_id) + .execute(pool) + .await?; + tracing::debug!( + rows_affected = result.rows_affected(), + key = origin.translation_key, + "Game setting locales: origin pin completed" + ); + Ok(()) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs b/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs new file mode 100644 index 0000000..3286af3 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs @@ -0,0 +1,83 @@ +//! Syncs selected values from Minecraft's `options.txt` between instances. +//! +//! When sync is first enabled, the user chooses an instance to supply the initial +//! values. Each instance keeps its own `options.txt`; we update only the selected +//! settings and leave modpack defaults, comments, and disabled settings alone. +//! +//! The watcher imports enabled values whenever `options.txt` changes. After the +//! app writes the file, it stores the file hash and ignores the matching watcher +//! event. On conflict, the latest save is persisted. +//! +//! Minecraft has renamed and reformatted settings over time. `catalog` +//! contains the file keys and conversions for the versions we support. Any other +//! key is treated as a custom setting and copied as-is to every synced instance. +//! Custom settings can be turned off individually in the settings screen. + +mod api_types; +mod catalog; +mod fullscreen; +mod instance_support; +mod launch_overrides; +mod local_settings_editor; +pub(crate) mod locales; +mod options_file; +mod pack_updates; +mod read_instance_changes; +mod resource_pack_options; +mod settings_editor; +mod source_selection; +mod write_shared_settings; + +pub use crate::state::{CanonicalValue, GameOptionKind}; +pub use api_types::{ + EditableGameSetting, GameOptionCompatibility, + GameOptionCompatibilityBucket, GameOptionCompatibilityReason, + GameOptionCompatibilityStatus, GameOptionEditorChoice, + GameOptionEditorDefinition, GameOptionMappingKind, + GameOptionValidationIssue, GameOptionValueState, + GameOptionsSourceCandidate, GameOptionsSourceIssue, GameSettingCategory, + GameSettingChange, GameSettingsEditorState, SaveGameSettingsResult, + 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, +}; +pub use locales::{GameSettingLocaleLabels, get_game_setting_locale_labels}; +pub use pack_updates::{GameOptionsPackSource, capture_pack_base}; +pub use settings_editor::{get_config, preview_changes, save_changes}; +pub use source_selection::list_sync_sources; + +pub(crate) use fullscreen::{ + shared_fullscreen_value, update_shared_fullscreen_from_app, +}; +pub(crate) use write_shared_settings::sync_all_participating_instances; + +pub(in crate::api::instance) use pack_updates::{ + detach_instance, prepare_instance_update_with_state, +}; +pub(in crate::api::instance) use resource_pack_options::{ + ResourcePackOptionsUpdate, merge_resource_pack_entries, + merge_resource_pack_order, read_resource_pack_entries, +}; +pub(in crate::api::instance) use source_selection::initialize_from_source_instance; +pub(in crate::api::instance) use write_shared_settings::{ + apply_instance_with_state, capture_instance_file_change, +}; + +pub(in crate::api::instance) async fn canonical_exists( + state: &crate::state::State, +) -> crate::Result { + crate::state::shared_game_options_exist(&state.pool).await +} + +const OPTIONS_FILE: &str = "options.txt"; +const CATALOG_REVISION: u32 = 6; +const MAX_OPTIONS_BYTES: usize = 2 * 1024 * 1024; +const MAX_OPTIONS_LINES: usize = 16_384; +const MAX_KEY_BYTES: usize = 1_024; +const MAX_VALUE_BYTES: usize = 32 * 1024; diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/options_file.rs b/packages/app-lib/src/api/instance/synced_options/game_options/options_file.rs new file mode 100644 index 0000000..f3cd05f --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/options_file.rs @@ -0,0 +1,360 @@ +//! Reads and edits `options.txt` without changing unrelated lines or file formatting. + +use super::catalog::{LEGACY_DATA_VERSIONS, release_version}; +use super::{ + MAX_KEY_BYTES, MAX_OPTIONS_BYTES, MAX_OPTIONS_LINES, MAX_VALUE_BYTES, + OPTIONS_FILE, +}; +use crate::ErrorKind; +use crate::state::{InstanceMetadata, State}; +use crate::util::io; +use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE}; +use sha1_smol::Sha1; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone)] +pub(super) struct GameOptionsDocument { + pub(super) encoding: &'static Encoding, + pub(super) bom: Bom, + pub(super) default_line_ending: String, + pub(super) lines: Vec, +} + +#[derive(Clone, Copy)] +pub(super) enum Bom { + None, + Utf8, + Utf16Le, + Utf16Be, +} + +#[derive(Clone)] +pub(super) struct GameOptionsLine { + pub(super) raw: String, + pub(super) ending: String, + pub(super) entry: Option, +} + +#[derive(Clone)] +pub(super) struct GameOptionsEntry { + pub(super) key: String, + pub(super) value: String, + pub(super) changed: bool, +} + +impl GameOptionsDocument { + pub(super) fn empty() -> Self { + Self { + encoding: UTF_8, + bom: Bom::None, + default_line_ending: "\n".to_string(), + lines: Vec::new(), + } + } + + /// Creates settings in the target game's format. Modern key bindings need + /// its data version so Minecraft does not run legacy migrations on them. + /// Unknown versions without embedded metadata wait for Minecraft's own file. + pub(super) async fn for_instance( + metadata: &InstanceMetadata, + state: &State, + ) -> crate::Result> { + let content_set = &metadata.applied_content_set; + let version = &content_set.game_version; + let version_jar = content_set.loader_version.as_ref().map_or_else( + || version.clone(), + |loader| format!("{version}-{loader}"), + ); + let path = state + .directories + .version_dir(&version_jar) + .join(format!("{version_jar}.jar")); + let embedded_version = if path.exists() { + crate::launcher::read_game_version_metadata_from_jar(&path) + .await? + .and_then(|metadata| metadata.world_version) + } else { + None + }; + let data_version = embedded_version + .or_else(|| LEGACY_DATA_VERSIONS.get(version.as_str()).copied()); + if data_version.is_none() + && !release_version(version) + .is_some_and(|(major, minor, _)| major == 1 && minor < 10) + { + return Ok(None); + } + let mut document = Self::empty(); + if let Some(data_version) = data_version { + document.set("version", &data_version.to_string(), true)?; + } + Ok(Some(document)) + } + + pub(super) fn parse(bytes: &[u8]) -> crate::Result { + if bytes.len() > MAX_OPTIONS_BYTES { + return Err(input_error("options.txt is too large to sync safely")); + } + + let (bom, encoding, contents) = + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + (Bom::Utf8, UTF_8, &bytes[3..]) + } else if bytes.starts_with(&[0xFF, 0xFE]) { + (Bom::Utf16Le, UTF_16LE, &bytes[2..]) + } else if bytes.starts_with(&[0xFE, 0xFF]) { + (Bom::Utf16Be, UTF_16BE, &bytes[2..]) + } else { + let mut detector = chardetng::EncodingDetector::new(); + detector.feed(bytes, true); + (Bom::None, detector.guess(None, true), bytes) + }; + let (decoded, decode_errors) = + encoding.decode_without_bom_handling(contents); + if decode_errors { + return Err(input_error( + "options.txt contains bytes that cannot be decoded losslessly", + )); + } + let roundtrip = encode_without_bom(encoding, &decoded)?; + if roundtrip.as_slice() != contents { + return Err(input_error( + "options.txt encoding cannot be round-tripped safely", + )); + } + + let mut lines = Vec::new(); + let mut start = 0; + let source: &str = decoded.as_ref(); + for (index, byte) in source.as_bytes().iter().enumerate() { + if *byte != b'\n' { + continue; + } + let raw_end = + if index > start && source.as_bytes()[index - 1] == b'\r' { + index - 1 + } else { + index + }; + let ending = if raw_end == index { "\n" } else { "\r\n" }; + lines.push(Self::parse_line(&source[start..raw_end], ending)); + start = index + 1; + } + if start < source.len() { + lines.push(Self::parse_line(&source[start..], "")); + } + if lines.len() > MAX_OPTIONS_LINES { + return Err(input_error( + "options.txt has too many lines to sync safely", + )); + } + let default_line_ending = lines + .iter() + .find(|line| !line.ending.is_empty()) + .map(|line| line.ending.clone()) + .unwrap_or_else(|| "\n".to_string()); + Ok(Self { + encoding, + bom, + default_line_ending, + lines, + }) + } + + pub(super) fn parse_line(raw: &str, ending: &str) -> GameOptionsLine { + let entry = raw.split_once(':').and_then(|(key, value)| { + if key.is_empty() + || key.len() > MAX_KEY_BYTES + || value.len() > MAX_VALUE_BYTES + { + None + } else { + Some(GameOptionsEntry { + key: key.to_string(), + value: value.to_string(), + changed: false, + }) + } + }); + GameOptionsLine { + raw: raw.to_string(), + ending: ending.to_string(), + entry, + } + } + + pub(super) fn effective_entries(&self) -> HashMap<&str, (usize, usize)> { + let mut entries = HashMap::new(); + for (index, line) in self.lines.iter().enumerate() { + if let Some(entry) = &line.entry { + entries + .entry(entry.key.as_str()) + .and_modify(|value: &mut (usize, usize)| { + value.0 = index; + value.1 += 1; + }) + .or_insert((index, 1)); + } + } + entries + } + + pub(super) fn value(&self, key: &str) -> Option<&str> { + let (index, _) = self.effective_entries().get(key).copied()?; + self.lines[index] + .entry + .as_ref() + .map(|entry| entry.value.as_str()) + } + + pub(super) fn set( + &mut self, + key: &str, + value: &str, + insert: bool, + ) -> crate::Result { + validate_raw_key_value(key, value)?; + if let Some((index, _)) = self.effective_entries().get(key).copied() { + let entry = self.lines[index].entry.as_mut().expect("entry index"); + if entry.value == value { + return Ok(false); + } + entry.value = value.to_string(); + entry.changed = true; + return Ok(true); + } + if !insert { + return Ok(false); + } + + let ending = if let Some(last) = self.lines.last_mut() { + if last.ending.is_empty() { + last.ending = self.default_line_ending.clone(); + String::new() + } else { + self.default_line_ending.clone() + } + } else { + self.default_line_ending.clone() + }; + self.lines.push(GameOptionsLine { + raw: String::new(), + ending, + entry: Some(GameOptionsEntry { + key: key.to_string(), + value: value.to_string(), + changed: true, + }), + }); + Ok(true) + } + + pub(super) fn serialize(&self) -> crate::Result> { + let mut text = String::new(); + for line in &self.lines { + match &line.entry { + Some(entry) if entry.changed => { + text.push_str(&entry.key); + text.push(':'); + text.push_str(&entry.value); + } + _ => text.push_str(&line.raw), + } + text.push_str(&line.ending); + } + let encoded = encode_without_bom(self.encoding, &text)?; + let bom: &[u8] = match self.bom { + Bom::None => &[], + Bom::Utf8 => &[0xEF, 0xBB, 0xBF], + Bom::Utf16Le => &[0xFF, 0xFE], + Bom::Utf16Be => &[0xFE, 0xFF], + }; + let mut result = Vec::with_capacity(bom.len() + encoded.len()); + result.extend_from_slice(bom); + result.extend_from_slice(&encoded); + Ok(result) + } +} + +pub(super) fn encode_without_bom( + encoding: &'static Encoding, + text: &str, +) -> crate::Result> { + if encoding == UTF_16LE || encoding == UTF_16BE { + let mut encoded = Vec::with_capacity(text.len().saturating_mul(2)); + for unit in text.encode_utf16() { + let bytes = if encoding == UTF_16LE { + unit.to_le_bytes() + } else { + unit.to_be_bytes() + }; + encoded.extend_from_slice(&bytes); + } + return Ok(encoded); + } + + let (encoded, _, had_errors) = encoding.encode(text); + if had_errors { + return Err(input_error( + "A synced option value cannot be represented in this file's encoding", + )); + } + Ok(encoded.into_owned()) +} + +pub(super) fn input_error(message: impl Into) -> crate::Error { + ErrorKind::InputError(message.into()).into() +} + +pub(super) fn validate_raw_key_value( + key: &str, + value: &str, +) -> crate::Result<()> { + if key.is_empty() + || key.len() > MAX_KEY_BYTES + || key.contains(':') + || key.chars().any(char::is_control) + { + return Err(input_error("Invalid options.txt setting key")); + } + if value.len() > MAX_VALUE_BYTES + || value + .chars() + .any(|character| character.is_control() && character != '\t') + { + return Err(input_error("Invalid options.txt setting value")); + } + Ok(()) +} + +pub(super) fn options_path( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + Ok(crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + )? + .join(OPTIONS_FILE)) +} + +pub(super) fn sha1_bytes(bytes: &[u8]) -> String { + Sha1::from(bytes).digest().to_string() +} + +/// Reads an `options.txt` only when it is safe to rewrite. +/// +/// The parser keeps its text encoding, line endings, comments, and duplicate keys. +pub(super) async fn read_document( + path: &Path, +) -> crate::Result<(GameOptionsDocument, Vec)> { + let metadata = tokio::fs::symlink_metadata(path) + .await + .map_err(|error| io::IOError::with_path(error, path))?; + if metadata.file_type().is_symlink() { + return Err(input_error("Refusing to sync a symlinked options.txt")); + } + let bytes = io::read(path).await?; + let document = GameOptionsDocument::parse(&bytes)?; + Ok((document, bytes)) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/pack_updates.rs b/packages/app-lib/src/api/instance/synced_options/game_options/pack_updates.rs new file mode 100644 index 0000000..c9672df --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/pack_updates.rs @@ -0,0 +1,302 @@ +//! Keeps game settings intact when a modpack is installed or updated. + +use super::MAX_OPTIONS_BYTES; +use super::options_file::{input_error, options_path, sha1_bytes}; +use super::write_shared_settings::{ + apply_shared_settings_to_instance, capture_instance_options, + sync_is_active_for_instance, +}; +use crate::state::{InstanceLink, InstanceMetadata, State, SyncedOption}; +use crate::util::io; +use encoding_rs::{UTF_8, UTF_16BE, UTF_16LE}; +use std::io::ErrorKind; +use std::path::Path; + +const YOSBR_OPTIONS_PATH: &str = "config/yosbr/options.txt"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GameOptionsPackSource { + ClientOverrides, + Overrides, + ClientOverridesYosbr, + OverridesYosbr, +} + +impl GameOptionsPackSource { + fn as_str(self) -> &'static str { + match self { + Self::ClientOverrides | Self::ClientOverridesYosbr => { + "client_overrides" + } + Self::Overrides | Self::OverridesYosbr => "overrides", + } + } + + fn is_yosbr(self) -> bool { + matches!(self, Self::ClientOverridesYosbr | Self::OverridesYosbr) + } +} + +fn detected_encoding_name(bytes: &[u8]) -> String { + if bytes.starts_with(&[0xFF, 0xFE]) { + return UTF_16LE.name().to_string(); + } + if bytes.starts_with(&[0xFE, 0xFF]) { + return UTF_16BE.name().to_string(); + } + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + return UTF_8.name().to_string(); + } + let mut detector = chardetng::EncodingDetector::new(); + detector.feed(bytes, true); + detector.guess(None, true).name().to_string() +} + +fn pack_version_id(metadata: &InstanceMetadata) -> Option<&str> { + match &metadata.link { + InstanceLink::ModrinthModpack { version_id, .. } => Some(version_id), + InstanceLink::ServerProjectModpack { + content_version_id, .. + } => Some(content_version_id), + InstanceLink::ImportedModpack { version_id, .. } => { + version_id.as_deref() + } + InstanceLink::SharedInstance { .. } => None, + _ => None, + } +} + +async fn read_pack_options( + path: &Path, + name: &str, +) -> crate::Result, String, String)>> { + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(io::IOError::with_path(error, path).into()), + }; + if metadata.file_type().is_symlink() { + return Err(input_error(format!( + "Refusing to capture a symlinked {name}" + ))); + } + if metadata.len() > MAX_OPTIONS_BYTES as u64 { + return Err(input_error(format!( + "The modpack {name} is too large to capture for syncing" + ))); + } + let bytes = io::read(path).await?; + let sha1 = sha1_bytes(&bytes); + let encoding = detected_encoding_name(&bytes); + Ok(Some((bytes, sha1, encoding))) +} + +async fn options_target_exists(path: &Path) -> crate::Result { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(input_error("Refusing to replace a symlinked options.txt")) + } + Ok(_) => Ok(true), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), + Err(error) => Err(io::IOError::with_path(error, path).into()), + } +} + +pub(super) async fn materialize_yosbr_options_if_missing( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let path = options_path(metadata, state)?; + if options_target_exists(&path).await? { + return Ok(()); + } + let template_path = + crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + )? + .join(YOSBR_OPTIONS_PATH); + let Some((bytes, _, _)) = + read_pack_options(&template_path, "YOSBR options.txt").await? + else { + return Ok(()); + }; + io::write(&path, bytes).await?; + Ok(()) +} + +pub(in crate::api::instance) async fn detach_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let feature = SyncedOption::GameOptions.as_str(); + sqlx::query( + "DELETE FROM instance_sync_checkpoints + WHERE instance_id = ? AND feature = ?", + ) + .bind(&metadata.instance.id) + .bind(feature) + .execute(&state.pool) + .await?; + Ok(()) +} + +/// Before replacing modpack files, saves `options.txt` and shares any settings the +/// player changed in Minecraft. +pub(in crate::api::instance) async fn prepare_instance_update_with_state( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + sqlx::query( + "DELETE FROM instance_game_option_update_state WHERE instance_id = ?", + ) + .bind(&metadata.instance.id) + .execute(&state.pool) + .await?; + if sync_is_active_for_instance(metadata, state).await? { + let _ = capture_instance_options(metadata, state, true).await?; + } + let path = options_path(metadata, state)?; + let (had_file, bytes, sha1) = if path.exists() { + let file_metadata = tokio::fs::symlink_metadata(&path) + .await + .map_err(|error| io::IOError::with_path(error, &path))?; + if file_metadata.file_type().is_symlink() { + return Err(input_error( + "Refusing to prepare a symlinked options.txt for a modpack update", + )); + } + if file_metadata.len() > MAX_OPTIONS_BYTES as u64 { + return Err(input_error( + "options.txt is too large to preserve for a modpack update", + )); + } + let bytes = io::read(&path).await?; + (true, Some(bytes.clone()), Some(sha1_bytes(&bytes))) + } else { + (false, None, None) + }; + sqlx::query( + "INSERT INTO instance_game_option_update_state + (instance_id, had_file, sha1, document) + VALUES (?, ?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET + had_file = excluded.had_file, sha1 = excluded.sha1, + document = excluded.document", + ) + .bind(&metadata.instance.id) + .bind(had_file) + .bind(sha1) + .bind(bytes) + .execute(&state.pool) + .await?; + Ok(()) +} + +/// Keeps the new modpack's `options.txt` as its default, then reapplies the user's +/// shared settings. +pub async fn capture_pack_base( + instance_id: &str, + source: Option, +) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| input_error("Unknown instance"))?; + let path = options_path(&metadata, &state)?; + let feature = SyncedOption::GameOptions.as_str(); + sqlx::query( + "DELETE FROM instance_sync_checkpoints + WHERE instance_id = ? AND feature = ?", + ) + .bind(instance_id) + .bind(feature) + .execute(&state.pool) + .await?; + + if source.is_none() { + let pending = sqlx::query( + "SELECT had_file, document + FROM instance_game_option_update_state + WHERE instance_id = ?", + ) + .bind(instance_id) + .fetch_optional(&state.pool) + .await?; + if let Some(pending) = pending { + use sqlx::Row; + let had_file: bool = pending.get("had_file"); + let document: Option> = pending.get("document"); + if had_file { + if let Some(document) = document { + io::write(&path, document).await?; + } + } else if path.exists() { + io::remove_file(&path).await?; + } + } + } + + let captured = if source.is_some_and(GameOptionsPackSource::is_yosbr) { + let template_path = + crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + )? + .join(YOSBR_OPTIONS_PATH); + let template = read_pack_options(&template_path, "YOSBR options.txt") + .await? + .ok_or_else(|| { + input_error("The modpack YOSBR options.txt is missing") + })?; + if !options_target_exists(&path).await? { + io::write(&path, template.0.clone()).await?; + } + Some(template) + } else { + read_pack_options(&path, "options.txt").await? + }; + let (document_bytes, sha1, encoding) = captured + .map(|(document, sha1, encoding)| { + (Some(document), Some(sha1), Some(encoding)) + }) + .unwrap_or((None, None, None)); + let source_name = + source.map(GameOptionsPackSource::as_str).unwrap_or("none"); + let version_id = pack_version_id(&metadata); + sqlx::query( + "INSERT INTO instance_game_option_pack_bases + (instance_id, pack_version_id, source, sha1, encoding, document) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET + pack_version_id = excluded.pack_version_id, + source = excluded.source, sha1 = excluded.sha1, + encoding = excluded.encoding, document = excluded.document", + ) + .bind(instance_id) + .bind(version_id) + .bind(source_name) + .bind(sha1) + .bind(encoding) + .bind(document_bytes) + .execute(&state.pool) + .await?; + sqlx::query( + "DELETE FROM instance_game_option_update_state WHERE instance_id = ?", + ) + .bind(instance_id) + .execute(&state.pool) + .await?; + + if sync_is_active_for_instance(&metadata, &state).await? + && let Err(error) = + apply_shared_settings_to_instance(&metadata, &state, true).await + { + tracing::warn!( + "Captured the modpack options.txt for {}, but shared settings could not be overlaid yet: {error}", + metadata.instance.id + ); + } + Ok(()) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/read_instance_changes.rs b/packages/app-lib/src/api/instance/synced_options/game_options/read_instance_changes.rs new file mode 100644 index 0000000..0c80bc3 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/read_instance_changes.rs @@ -0,0 +1,371 @@ +//! Imports saved `options.txt` settings into the canonical values. + +use super::CATALOG_REVISION; +use super::api_types::canonical_values_equal; +use super::catalog::*; +use super::fullscreen::update_app_fullscreen_setting; +use super::launch_overrides::currently_launcher_owned_keys; +use super::options_file::{GameOptionsDocument, validate_raw_key_value}; +use crate::state::{ + CanonicalValue, GameOptionKind, InstanceMetadata, State, + load_game_option_preferences, load_game_options_sync_state, + load_shared_game_options, +}; +use chrono::Utc; +use std::collections::HashSet; + +pub(super) fn custom_setting_id(key: &str) -> String { + format!("external:{key}") +} + +/// Imports every enabled setting present in `options.txt`. The caller skips file +/// hashes produced by app writes. +pub(super) async fn read_instance_changes_into_shared_settings( + metadata: &InstanceMetadata, + document: &GameOptionsDocument, + state: &State, +) -> crate::Result { + let values = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let launcher_keys = currently_launcher_owned_keys(metadata); + let now = Utc::now().timestamp(); + let mut updates = Vec::new(); + for (option_id, stored) in &values { + if !preferences + .get(option_id) + .is_some_and(|preference| preference.enabled) + { + continue; + } + if matches!(stored.kind, GameOptionKind::External) + && stored.raw_key.is_none() + { + continue; + } + if stored + .raw_key + .as_deref() + .is_some_and(|key| setting_by_file_key(key).is_some()) + { + continue; + } + let definition = setting_by_id(option_id); + if definition.is_some() + && !supported_settings_cover_game_version( + &metadata.applied_content_set.game_version, + ) + { + continue; + } + let current_key = if let Some(definition) = definition { + observed_physical_key( + definition, + document, + &metadata.applied_content_set.game_version, + ) + } else { + stored.raw_key.clone() + }; + let Some(current_key) = current_key else { + continue; + }; + if launcher_keys.contains(¤t_key) { + continue; + } + let Some(current_raw) = document.value(¤t_key) else { + continue; + }; + let candidate = if let Some(definition) = definition { + decode_value_for_version( + definition, + ¤t_key, + current_raw, + &metadata.applied_content_set.game_version, + ) + } else if matches!(stored.kind, GameOptionKind::External) { + if validate_raw_key_value(¤t_key, current_raw).is_err() { + continue; + } + Some(CanonicalValue::ExternalRaw(current_raw.to_string())) + } else { + None + }; + let Some(candidate) = candidate else { + continue; + }; + if stored + .value + .as_ref() + .is_some_and(|current| canonical_values_equal(current, &candidate)) + { + continue; + } + updates.push((option_id.to_string(), candidate, stored.revision)); + } + + let mut changed = false; + let mut fullscreen_value = None; + if !updates.is_empty() { + let (canonical_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let mut tx = state.pool.begin().await?; + for (option_id, candidate, revision) in updates { + let next_revision = revision.saturating_add(1) as i64; + let current_revision = revision as i64; + let value_json = serde_json::to_string(&candidate)?; + let canonical_type = candidate.type_name(); + let result = sqlx::query( + "UPDATE synced_game_option_values + SET canonical_type = ?, canonical_value_json = ?, seeded = 1, + revision = ?, origin = 'instance', source_game_version = ?, + source_instance_id = ?, updated_at = ? + WHERE option_id = ? AND revision = ?", + ) + .bind(canonical_type) + .bind(value_json) + .bind(next_revision) + .bind(&metadata.applied_content_set.game_version) + .bind(&metadata.instance.id) + .bind(now) + .bind(&option_id) + .bind(current_revision) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + continue; + } + if option_id == "fullscreen" { + fullscreen_value = Some(candidate.clone()); + } + changed = true; + } + if changed { + let next_revision = canonical_revision.saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_state + SET revision = ?, catalog_revision = ? + WHERE singleton = 1", + ) + .bind(next_revision) + .bind(CATALOG_REVISION as i64) + .execute(&mut *tx) + .await?; + } + if let Some(value) = fullscreen_value.as_ref() { + update_app_fullscreen_setting(&mut tx, value, true).await?; + } + tx.commit().await?; + } + let discovered = + discover_custom_settings(metadata, document, &launcher_keys, state) + .await?; + Ok(discovered || changed) +} + +/// Finds settings added by mods and adds them to the shared settings list. +pub(super) async fn discover_custom_settings( + metadata: &InstanceMetadata, + document: &GameOptionsDocument, + excluded_keys: &HashSet, + state: &State, +) -> crate::Result { + let existing = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let (canonical_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let entries = document.effective_entries(); + let now = Utc::now().timestamp(); + let mut discovered = false; + let locale_snapshot = + if super::locales::has_localizable_mod_settings(document) + && document.effective_entries().keys().any(|key| { + let id = setting_by_file_key(key) + .map(|s| s.id.to_owned()) + .unwrap_or_else(|| custom_setting_id(key)); + !existing.contains_key(&id) + }) + { + super::locales::capture_observation(metadata, state).await + } else { + None + }; + let mut tx = state.pool.begin().await?; + let game_version = metadata.applied_content_set.game_version.as_str(); + + for definition in all_supported_settings() { + for key in definition.keys { + let custom_id = custom_setting_id(key); + let Some(custom) = existing.get(&custom_id) else { + continue; + }; + if !custom.seeded { + continue; + } + let revision = custom.revision.saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_values + SET seeded = 0, revision = ?, updated_at = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(now) + .bind(&custom_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE synced_game_option_preferences + SET enabled = 0, source = 'catalog_default', revision = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(&custom_id) + .execute(&mut *tx) + .await?; + discovered = true; + } + } + + if supported_settings_cover_game_version(game_version) { + for definition in all_supported_settings() { + if existing.contains_key(definition.id) { + continue; + } + let discovered_value = definition.keys.iter().find_map(|key| { + if excluded_keys.contains(*key) { + return None; + } + let raw = document.value(key)?; + decode_value_for_version(definition, key, raw, game_version) + }); + let Some(value) = discovered_value else { + continue; + }; + let value_json = serde_json::to_string(&value)?; + let canonical_type = value.type_name(); + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, 'vanilla', NULL, ?, ?, 'catalog', 1, 1, + 'instance', ?, ?, ?)", + ) + .bind(definition.id) + .bind(canonical_type) + .bind(value_json) + .bind(game_version) + .bind(&metadata.instance.id) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, ?, 'catalog_default', 1)", + ) + .bind(definition.id) + .bind(definition.default_on) + .execute(&mut *tx) + .await?; + discovered = true; + } + } + + for (key, (index, _)) in entries { + if excluded_keys.contains(key) + || setting_by_file_key(key).is_some() + || is_never_sync_key(key) + { + continue; + } + let Some(entry) = &document.lines[index].entry else { + continue; + }; + if validate_raw_key_value(key, &entry.value).is_err() { + continue; + } + let option_id = custom_setting_id(key); + let should_seed = + existing.get(&option_id).is_none_or(|stored| !stored.seeded); + if !should_seed { + continue; + } + let revision = existing + .get(&option_id) + .map(|stored| stored.revision) + .unwrap_or(0) + .max( + preferences + .get(&option_id) + .map(|preference| preference.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + let value = CanonicalValue::ExternalRaw(entry.value.clone()); + let value_json = serde_json::to_string(&value)?; + + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, 'external', ?, 'external_raw', ?, 'exact_raw', + 1, ?, 'instance', ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + raw_key = excluded.raw_key, + canonical_value_json = excluded.canonical_value_json, + seeded = 1, revision = excluded.revision, + origin = 'instance', + source_game_version = excluded.source_game_version, + source_instance_id = excluded.source_instance_id, + updated_at = excluded.updated_at", + ) + .bind(&option_id) + .bind(key) + .bind(value_json) + .bind(revision) + .bind(game_version) + .bind(&metadata.instance.id) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + " + INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, 0, 'discovery_default', ?) + ON CONFLICT(option_id) DO NOTHING + ", + ) + .bind(&option_id) + .bind(revision) + .execute(&mut *tx) + .await?; + discovered = true; + } + + if discovered { + let next_revision = canonical_revision.saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_state + SET revision = ?, catalog_revision = ? + WHERE singleton = 1", + ) + .bind(next_revision) + .bind(CATALOG_REVISION as i64) + .execute(&mut *tx) + .await?; + } + super::locales::record_observations( + &mut tx, + metadata, + document, + locale_snapshot.as_deref(), + ) + .await; + tx.commit().await?; + if discovered { + super::locales::queue_game_locale_index(); + } + Ok(discovered) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/resource_pack_options.rs b/packages/app-lib/src/api/instance/synced_options/game_options/resource_pack_options.rs new file mode 100644 index 0000000..676c2fe --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/resource_pack_options.rs @@ -0,0 +1,112 @@ +//! Reads and merges Minecraft's active resource-pack list. + +use super::super::{instance_is_running, sync_files_are_protected}; +use super::options_file::{input_error, options_path, read_document}; +use crate::state::{InstanceMetadata, State}; +use crate::util::io; +use std::collections::BTreeSet; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::api::instance) struct ResourcePackOptions { + pub entries: Vec, + pub incompatible: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::api::instance) enum ResourcePackOptionsUpdate { + Deferred, + Applied(ResourcePackOptions), +} + +fn parse_entries(raw: &str) -> crate::Result> { + serde_json::from_str(raw).map_err(|_| { + input_error("options.txt contains an invalid resourcePacks value") + }) +} + +pub(in crate::api::instance) fn merge_resource_pack_order( + entries: &[String], + managed: &BTreeSet, + selected: &[String], +) -> Vec { + let mut merged = Vec::new(); + let mut remaining = selected.iter(); + let mut seen = BTreeSet::new(); + for entry in entries { + if !managed.contains(entry) { + merged.push(entry.clone()); + } else if selected.contains(entry) + && seen.insert(entry) + && let Some(next) = remaining.next() + { + merged.push(next.clone()); + } + } + merged.extend(remaining.cloned()); + merged +} + +pub(in crate::api::instance) async fn read_resource_pack_entries( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + let path = options_path(metadata, state)?; + if !path.exists() { + return Ok(None); + } + let (document, _) = read_document(&path).await?; + let Some(raw) = document.value("resourcePacks") else { + return Ok(None); + }; + Ok(Some(ResourcePackOptions { + entries: parse_entries(raw)?, + incompatible: document + .value("incompatibleResourcePacks") + .map(parse_entries) + .transpose()? + .unwrap_or_default(), + })) +} + +pub(in crate::api::instance) async fn merge_resource_pack_entries( + metadata: &InstanceMetadata, + managed: &BTreeSet, + selected: &[String], + state: &State, +) -> crate::Result { + if sync_files_are_protected(metadata) { + return Ok(ResourcePackOptionsUpdate::Deferred); + } + let path = options_path(metadata, state)?; + if !path.exists() { + return Ok(ResourcePackOptionsUpdate::Deferred); + } + let (mut document, input_bytes) = read_document(&path).await?; + let Some(raw) = document.value("resourcePacks") else { + return Ok(ResourcePackOptionsUpdate::Deferred); + }; + let original = parse_entries(raw)?; + let entries = merge_resource_pack_order(&original, managed, selected); + let options = ResourcePackOptions { + entries, + incompatible: document + .value("incompatibleResourcePacks") + .map(parse_entries) + .transpose()? + .unwrap_or_default(), + }; + if options.entries == original { + return Ok(ResourcePackOptionsUpdate::Applied(options)); + } + if instance_is_running(metadata, state).await? { + return Ok(ResourcePackOptionsUpdate::Deferred); + } + let raw = serde_json::to_string(&options.entries)?; + document.set("resourcePacks", &raw, false)?; + let output_bytes = document.serialize()?; + if output_bytes == input_bytes { + return Ok(ResourcePackOptionsUpdate::Applied(options)); + } + io::write(path, output_bytes).await?; + Ok(ResourcePackOptionsUpdate::Applied(options)) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/settings_editor.rs b/packages/app-lib/src/api/instance/synced_options/game_options/settings_editor.rs new file mode 100644 index 0000000..e85b865 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/settings_editor.rs @@ -0,0 +1,522 @@ +//! Loads, previews, and saves the game settings shown in the app. + +use super::CATALOG_REVISION; +use super::api_types::*; +use super::catalog::*; +use super::fullscreen::update_app_fullscreen_setting; +use super::instance_support::{ + describe_instance_support, find_common_local_value, + load_participating_instances, summary_revision, +}; +use super::options_file::input_error; +use super::write_shared_settings::sync_all_participating_instances; +use crate::state::{ + CanonicalValue, GameOptionKind, State, StoredOption, + game_options_sync_is_enabled, load_game_option_preferences, + load_game_options_sync_state, load_shared_game_options, +}; +use chrono::Utc; + +fn validation_issue( + sync_enabled: bool, + value: Option<&CanonicalValue>, + compatibility: &GameOptionCompatibility, +) -> Option { + if !sync_enabled { + return None; + } + if value.is_none() { + return Some(GameOptionValidationIssue::MissingValue); + } + if compatibility.total_participating > 0 && compatibility.will_receive == 0 + { + return Some(GameOptionValidationIssue::NoCompatibleInstances); + } + None +} + +/// Loads everything shown in the game-settings modal. +/// +/// It returns message IDs and setting details; the frontend provides the translated +/// labels and explanations. +pub(super) async fn load_settings_editor( + state: &State, +) -> crate::Result { + let (canonical_revision, catalog_revision) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let values = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let participants = load_participating_instances(state).await?; + let mut settings = Vec::new(); + + for definition in all_supported_settings() { + let retained = values.contains_key(definition.id) + || preferences.contains_key(definition.id); + let observed = participants.iter().any(|participant| { + participant.document.as_ref().is_some_and(|document| { + definition + .keys + .iter() + .any(|key| document.value(key).is_some()) + }) + }); + if !retained && !observed { + continue; + } + let stored = values.get(definition.id).cloned().unwrap_or_else(|| { + StoredOption { + option_id: definition.id.to_string(), + kind: GameOptionKind::Vanilla, + raw_key: None, + value: None, + seeded: false, + revision: 0, + } + }); + let preference = preferences.get(definition.id); + let (canonical_value, value_state) = if stored.seeded { + (stored.value.clone(), GameOptionValueState::Canonical) + } else { + find_common_local_value(Some(definition), &stored, &participants) + }; + let compatibility = describe_instance_support( + Some(definition), + &stored, + canonical_value.as_ref(), + &participants, + ); + let sync_enabled = + preference.map(|value| value.enabled).unwrap_or(false); + let validation_error = validation_issue( + sync_enabled, + canonical_value.as_ref(), + &compatibility, + ); + settings.push(EditableGameSetting { + option_id: definition.id.to_string(), + category_id: definition.category.to_string(), + kind: GameOptionKind::Vanilla, + raw_key: None, + sync_enabled, + canonical_value, + value_state, + option_revision: stored + .revision + .max(preference.map(|value| value.revision).unwrap_or(0)), + editor: editor_for(definition), + compatibility, + validation_error, + controlled: false, + }); + } + + let mut custom_values = values + .values() + .filter(|value| { + matches!(value.kind, GameOptionKind::External) + && value + .raw_key + .as_deref() + .is_none_or(|key| setting_by_file_key(key).is_none()) + }) + .cloned() + .collect::>(); + custom_values.sort_by(|left, right| left.raw_key.cmp(&right.raw_key)); + for stored in custom_values { + let preference = preferences.get(&stored.option_id); + let (canonical_value, value_state) = if stored.seeded { + (stored.value.clone(), GameOptionValueState::Canonical) + } else { + find_common_local_value(None, &stored, &participants) + }; + let compatibility = describe_instance_support( + None, + &stored, + canonical_value.as_ref(), + &participants, + ); + let sync_enabled = preference.is_some_and(|value| value.enabled); + let validation_error = validation_issue( + sync_enabled, + canonical_value.as_ref(), + &compatibility, + ); + settings.push(EditableGameSetting { + option_id: stored.option_id.clone(), + category_id: "custom".to_string(), + kind: GameOptionKind::External, + raw_key: stored.raw_key.clone(), + sync_enabled, + canonical_value, + value_state, + option_revision: stored + .revision + .max(preference.map(|value| value.revision).unwrap_or(0)), + editor: custom_setting_editor(), + compatibility, + validation_error, + controlled: false, + }); + } + + let category_definitions = [ + "skin_customization", + "video", + "language", + "music_and_sound", + "controls", + "chat", + "accessibility", + "online", + "custom", + ]; + let categories = category_definitions + .into_iter() + .filter(|id| { + *id == "custom" + || settings.iter().any(|setting| setting.category_id == *id) + }) + .map(|id| GameSettingCategory { + id: id.to_string(), + is_custom: id == "custom", + }) + .collect(); + + Ok(GameSettingsEditorState { + summary_revision: summary_revision(canonical_revision, &participants), + canonical_revision, + catalog_revision, + total_participating: participants.len() as u32, + categories, + settings, + }) +} + +pub async fn get_config() -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + load_settings_editor(&state).await +} + +/// Previews how the user's unsaved changes would apply to their instances. +pub async fn preview_changes( + request: UpdateGameSettingsRequest, +) -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + if request.expected_catalog_revision != CATALOG_REVISION { + return load_settings_editor(&state).await; + } + let mut editor_state = load_settings_editor(&state).await?; + let participants = load_participating_instances(&state).await?; + for change in request.changes { + let Some(setting) = editor_state + .settings + .iter_mut() + .find(|setting| setting.option_id == change.option_id) + else { + continue; + }; + if setting.option_revision != change.base_option_revision { + setting.validation_error = + Some(GameOptionValidationIssue::ChangedSinceOpened); + continue; + } + if let Some(value) = change.canonical_value { + let definition = setting_by_id(&setting.option_id); + if let Some(value) = value { + if validate_canonical_value(definition, &value).is_err() { + setting.validation_error = + Some(GameOptionValidationIssue::InvalidValue); + } else { + setting.canonical_value = Some(value); + setting.value_state = GameOptionValueState::Canonical; + setting.validation_error = None; + } + } else { + setting.canonical_value = None; + setting.value_state = GameOptionValueState::Unset; + setting.validation_error = None; + } + } + if let Some(enabled) = change.sync_enabled { + setting.sync_enabled = enabled; + } + let stored = StoredOption { + option_id: setting.option_id.clone(), + kind: setting.kind, + raw_key: setting.raw_key.clone(), + value: setting.canonical_value.clone(), + seeded: setting.canonical_value.is_some(), + revision: setting.option_revision, + }; + setting.compatibility = describe_instance_support( + setting_by_id(&setting.option_id), + &stored, + setting.canonical_value.as_ref(), + &participants, + ); + if setting.sync_enabled && setting.canonical_value.is_none() { + setting.validation_error = + Some(GameOptionValidationIssue::MissingValue); + } else if setting.sync_enabled + && setting.compatibility.total_participating > 0 + && setting.compatibility.will_receive == 0 + { + setting.validation_error = + Some(GameOptionValidationIssue::NoCompatibleInstances); + } + } + Ok(editor_state) +} + +/// Saves changes if nothing has changed since the modal opened, then updates each +/// instance that can receive them. +pub async fn save_changes( + request: UpdateGameSettingsRequest, +) -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + if !game_options_sync_is_enabled(&state.pool).await? { + return Err(input_error( + "Turn on game-settings sync before editing shared values.", + )); + } + let current_editor = load_settings_editor(&state).await?; + 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 stored_values = load_shared_game_options(&state.pool).await?; + let participants = load_participating_instances(&state).await?; + let mut conflicts = Vec::new(); + let mut accepted = Vec::new(); + + for mut change in changes { + let Some(setting) = current_editor + .settings + .iter() + .find(|setting| setting.option_id == change.option_id) + else { + return Err(input_error(format!( + "Unknown game setting {}", + change.option_id + ))); + }; + if setting.option_revision != change.base_option_revision { + conflicts.push(change.option_id); + continue; + } + if let Some(Some(value)) = &change.canonical_value { + validate_canonical_value(setting_by_id(&setting.option_id), value)?; + } + let resulting_enabled = + change.sync_enabled.unwrap_or(setting.sync_enabled); + let stored_seeded = stored_values + .get(&setting.option_id) + .is_some_and(|stored| stored.seeded); + if resulting_enabled + && !stored_seeded + && change.canonical_value.is_none() + && setting.canonical_value.is_some() + { + change.canonical_value = Some(setting.canonical_value.clone()); + } + let resulting_value = match &change.canonical_value { + Some(value) => value.as_ref(), + None => setting.canonical_value.as_ref(), + }; + if resulting_enabled { + if resulting_value.is_none() { + return Err(input_error(format!( + "Choose a value before enabling {}", + setting.option_id + ))); + } + let projected = StoredOption { + option_id: setting.option_id.clone(), + kind: setting.kind, + raw_key: setting.raw_key.clone(), + value: resulting_value.cloned(), + seeded: resulting_value.is_some(), + revision: setting.option_revision, + }; + let compatibility = describe_instance_support( + setting_by_id(&setting.option_id), + &projected, + resulting_value, + &participants, + ); + if compatibility.total_participating > 0 + && compatibility.will_receive == 0 + { + return Err(input_error(format!( + "{} is not compatible with any participating instance", + setting.option_id + ))); + } + } + accepted.push((setting.clone(), change)); + } + + let mut changed = false; + let mut fullscreen_value = None; + let mut tx = state.pool.begin().await?; + for (setting, change) in accepted { + let resulting_enabled = + change.sync_enabled.unwrap_or(setting.sync_enabled); + let value_changed = + change.canonical_value.as_ref().is_some_and(|value| { + setting.value_state != GameOptionValueState::Canonical + || value.as_ref() != setting.canonical_value.as_ref() + }); + let selection_changed = change + .sync_enabled + .is_some_and(|enabled| enabled != setting.sync_enabled); + if !value_changed && !selection_changed { + continue; + } + if resulting_enabled + && setting.option_id == "fullscreen" + && let Some(value) = change + .canonical_value + .as_ref() + .and_then(|value| value.as_ref()) + .or(setting.canonical_value.as_ref()) + { + fullscreen_value = Some(value.clone()); + } + changed = true; + let revision = setting.option_revision.saturating_add(1) as i64; + if let Some(value) = change.canonical_value { + let now = Utc::now().timestamp(); + if let Some(value) = value { + let value_json = serde_json::to_string(&value)?; + let canonical_type = value.type_name(); + let kind = if matches!(setting.kind, GameOptionKind::External) { + "external" + } else { + "vanilla" + }; + let raw_key = setting.raw_key.as_deref(); + let value_codec = if setting_by_id(&setting.option_id).is_some() + { + "catalog" + } else { + "exact_raw" + }; + let source_game_version: Option<&str> = None; + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, 'app_editor', ?, NULL, ?) + ON CONFLICT(option_id) DO UPDATE SET + kind = excluded.kind, raw_key = excluded.raw_key, + canonical_type = excluded.canonical_type, + canonical_value_json = excluded.canonical_value_json, + value_codec = excluded.value_codec, seeded = 1, + revision = excluded.revision, origin = 'app_editor', + source_game_version = NULL, source_instance_id = NULL, + updated_at = excluded.updated_at", + ) + .bind(&setting.option_id) + .bind(kind) + .bind(raw_key) + .bind(canonical_type) + .bind(value_json) + .bind(value_codec) + .bind(revision) + .bind(source_game_version) + .bind(now) + .execute(&mut *tx) + .await?; + } else { + sqlx::query( + "UPDATE synced_game_option_values + SET canonical_value_json = NULL, seeded = 0, revision = ?, + origin = 'app_editor', source_game_version = NULL, + source_instance_id = NULL, updated_at = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(now) + .bind(&setting.option_id) + .execute(&mut *tx) + .await?; + } + } + if let Some(enabled) = change.sync_enabled { + sqlx::query( + "INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + enabled = excluded.enabled, source = 'user', + revision = excluded.revision", + ) + .bind(&setting.option_id) + .bind(enabled) + .bind("user") + .bind(revision) + .execute(&mut *tx) + .await?; + } + } + if changed { + let next_revision = + current_editor.canonical_revision.saturating_add(1) as i64; + let catalog_revision = CATALOG_REVISION as i64; + sqlx::query( + "INSERT INTO synced_game_option_state (singleton, revision, catalog_revision) + VALUES (1, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + revision = excluded.revision, + catalog_revision = excluded.catalog_revision", + ) + .bind(next_revision) + .bind(catalog_revision) + .execute(&mut *tx) + .await?; + } + if let Some(value) = fullscreen_value.as_ref() { + update_app_fullscreen_setting(&mut tx, value, true).await?; + } + tx.commit().await?; + + let write_result = if changed { + sync_all_participating_instances(&state).await + } else { + SaveGameSettingsResult { + state: None, + applied: 0, + migrated: 0, + deferred: 0, + unsupported: 0, + failed: 0, + conflicts: Vec::new(), + } + }; + let result = SaveGameSettingsResult { + state: Some(load_settings_editor(&state).await?), + conflicts, + ..write_result + }; + Ok(result) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/source_selection.rs b/packages/app-lib/src/api/instance/synced_options/game_options/source_selection.rs new file mode 100644 index 0000000..1d94bf9 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/source_selection.rs @@ -0,0 +1,409 @@ +//! Finds eligible source instances and copies the first shared settings from one. + +use super::super as synced_options; +use super::CATALOG_REVISION; +use super::api_types::{GameOptionsSourceCandidate, GameOptionsSourceIssue}; +use super::catalog::*; +use super::fullscreen::update_app_fullscreen_setting; +use super::launch_overrides::currently_launcher_owned_keys; +use super::options_file::{ + input_error, options_path, read_document, validate_raw_key_value, +}; +use super::read_instance_changes::custom_setting_id; +use crate::state::{ + CanonicalValue, InstanceMetadata, State, load_game_option_preferences, + load_game_options_sync_state, load_shared_game_options, +}; +use chrono::Utc; + +/// Uses the selected instance's `options.txt` as the initial shared settings. +pub(in crate::api::instance) async fn initialize_from_source_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let path = options_path(metadata, state)?; + if !path.exists() { + return Err(input_error( + "Launch the source instance once so Minecraft can create options.txt.", + )); + } + let (document, _) = read_document(&path).await?; + let launcher_keys = currently_launcher_owned_keys(metadata); + let entries = document.effective_entries(); + let current_values = load_shared_game_options(&state.pool).await?; + let current_preferences = load_game_option_preferences(&state.pool).await?; + let (current_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + let now = Utc::now().timestamp(); + let source_version = metadata.applied_content_set.game_version.as_str(); + let source_id = metadata.instance.id.as_str(); + let locale_snapshot = + if super::locales::has_localizable_mod_settings(&document) { + super::locales::capture_observation(metadata, state).await + } else { + None + }; + let mut tx = state.pool.begin().await?; + let catalog_revision = CATALOG_REVISION as i64; + + sqlx::query( + "INSERT INTO sync_feature_settings + (feature, globally_enabled, new_instance_default) + VALUES ('game_options', 1, 1) + ON CONFLICT(feature) DO UPDATE SET globally_enabled = 1", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO instance_sync_preferences (instance_id, feature, enabled) + VALUES (?, 'game_options', 1) + ON CONFLICT(instance_id, feature) DO UPDATE SET enabled = 1", + ) + .bind(source_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO synced_game_option_state (singleton, revision, catalog_revision) + VALUES (1, 0, ?) + ON CONFLICT(singleton) DO UPDATE SET + catalog_revision = excluded.catalog_revision", + ) + .bind(catalog_revision) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE synced_game_option_values + SET seeded = 0, revision = revision + 1, updated_at = ?", + ) + .bind(now) + .execute(&mut *tx) + .await?; + for definition in all_supported_settings() { + for key in definition.keys { + let custom_id = custom_setting_id(key); + let Some(custom) = current_values.get(&custom_id) else { + continue; + }; + let revision = custom + .revision + .max( + current_preferences + .get(&custom_id) + .map(|preference| preference.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_values + SET seeded = 0, revision = ?, updated_at = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(now) + .bind(&custom_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE synced_game_option_preferences + SET enabled = 0, source = 'catalog_default', revision = ? + WHERE option_id = ?", + ) + .bind(revision) + .bind(&custom_id) + .execute(&mut *tx) + .await?; + } + } + let mut seeded_any = false; + let mut fullscreen_value = None; + let mut fullscreen_sync_enabled = false; + + for definition in all_supported_settings() { + if !supported_settings_cover_game_version(source_version) { + break; + } + let found = definition.keys.iter().find_map(|key| { + if launcher_keys.contains(*key) { + return None; + } + let (index, _) = entries.get(*key).copied()?; + let entry = document.lines[index].entry.as_ref()?; + decode_value_for_version( + definition, + key, + &entry.value, + source_version, + ) + .map(|value| ((*key).to_string(), value)) + }); + let Some((_physical_key, value)) = found else { + continue; + }; + if definition.id == "fullscreen" { + fullscreen_value = Some(value.clone()); + fullscreen_sync_enabled = current_preferences + .get(definition.id) + .map(|preference| preference.enabled) + .unwrap_or(definition.default_on); + } + seeded_any = true; + let option_revision = current_values + .get(definition.id) + .map(|value| value.revision) + .unwrap_or(0) + .max( + current_preferences + .get(definition.id) + .map(|value| value.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + let value_json = serde_json::to_string(&value)?; + let canonical_type = value.type_name(); + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, 'vanilla', NULL, ?, ?, 'catalog', 1, ?, + 'source_seed', ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + kind = 'vanilla', raw_key = NULL, + canonical_type = excluded.canonical_type, + canonical_value_json = excluded.canonical_value_json, + value_codec = excluded.value_codec, seeded = 1, + revision = excluded.revision, origin = 'source_seed', + source_game_version = excluded.source_game_version, + source_instance_id = excluded.source_instance_id, + updated_at = excluded.updated_at", + ) + .bind(definition.id) + .bind(canonical_type) + .bind(value_json) + .bind(option_revision) + .bind(source_version) + .bind(source_id) + .bind(now) + .execute(&mut *tx) + .await?; + let default_enabled = definition.default_on; + sqlx::query( + "INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, ?, 'catalog_default', ?) + ON CONFLICT(option_id) DO NOTHING", + ) + .bind(definition.id) + .bind(default_enabled) + .bind(option_revision) + .execute(&mut *tx) + .await?; + } + + for (key, (index, _)) in entries { + if launcher_keys.contains(key) + || setting_by_file_key(key).is_some() + || is_never_sync_key(key) + { + continue; + } + let Some(entry) = &document.lines[index].entry else { + continue; + }; + if validate_raw_key_value(key, &entry.value).is_err() { + continue; + } + let option_id = custom_setting_id(key); + let value = CanonicalValue::ExternalRaw(entry.value.clone()); + seeded_any = true; + let option_revision = current_values + .get(&option_id) + .map(|value| value.revision) + .unwrap_or(0) + .max( + current_preferences + .get(&option_id) + .map(|value| value.revision) + .unwrap_or(0), + ) + .saturating_add(1) as i64; + let value_json = serde_json::to_string(&value)?; + sqlx::query( + "INSERT INTO synced_game_option_values + (option_id, kind, raw_key, canonical_type, + canonical_value_json, value_codec, seeded, revision, origin, + source_game_version, source_instance_id, updated_at) + VALUES (?, 'external', ?, 'external_raw', ?, 'exact_raw', + 1, ?, 'source_seed', ?, ?, ?) + ON CONFLICT(option_id) DO UPDATE SET + raw_key = excluded.raw_key, + canonical_value_json = excluded.canonical_value_json, + seeded = 1, revision = excluded.revision, + origin = 'source_seed', + source_game_version = excluded.source_game_version, + source_instance_id = excluded.source_instance_id, + updated_at = excluded.updated_at", + ) + .bind(&option_id) + .bind(key) + .bind(value_json) + .bind(option_revision) + .bind(source_version) + .bind(source_id) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + " + INSERT INTO synced_game_option_preferences + (option_id, enabled, source, revision) + VALUES (?, 0, 'discovery_default', ?) + ON CONFLICT(option_id) DO NOTHING + ", + ) + .bind(&option_id) + .bind(option_revision) + .execute(&mut *tx) + .await?; + } + if !seeded_any { + return Err(input_error( + "The selected options.txt has no settings that can be safely used as a sync baseline.", + )); + } + + let next_revision = current_revision.saturating_add(1) as i64; + sqlx::query( + "UPDATE synced_game_option_state + SET revision = ?, catalog_revision = ? + WHERE singleton = 1", + ) + .bind(next_revision) + .bind(catalog_revision) + .execute(&mut *tx) + .await?; + if let Some(value) = fullscreen_value.as_ref() { + update_app_fullscreen_setting(&mut tx, value, fullscreen_sync_enabled) + .await?; + } + super::locales::record_observations( + &mut tx, + metadata, + &document, + locale_snapshot.as_deref(), + ) + .await; + tx.commit().await?; + super::locales::queue_game_locale_index(); + Ok(()) +} + +pub async fn list_sync_sources() +-> crate::Result> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let instances = crate::state::list_instances(&state.pool).await?; + let mut sources = Vec::with_capacity(instances.len()); + for metadata in instances { + let path = options_path(&metadata, &state)?; + let version_supported = supported_settings_cover_game_version( + &metadata.applied_content_set.game_version, + ); + let mut eligible = true; + let mut disabled_reason = None; + let mut recognized_setting_count = 0; + let mut custom_setting_count = 0; + let installing = synced_options::sync_files_are_protected(&metadata); + if !installing && !path.exists() { + eligible = false; + disabled_reason = Some(GameOptionsSourceIssue::MissingOptionsFile); + } else if !installing { + match read_document(&path).await { + Ok((document, _)) => { + let launcher_keys = + currently_launcher_owned_keys(&metadata); + let entries = document.effective_entries(); + let source_version = + metadata.applied_content_set.game_version.as_str(); + let recognized = if version_supported { + all_supported_settings() + .filter(|setting| { + setting.keys.iter().any(|key| { + if launcher_keys.contains(*key) { + return false; + } + document.value(key).is_some_and(|raw| { + decode_value_for_version( + setting, + key, + raw, + source_version, + ) + .is_some() + }) + }) + }) + .count() + } else { + 0 + }; + let custom = entries + .into_iter() + .filter(|(key, (index, _))| { + !launcher_keys.contains(*key) + && setting_by_file_key(key).is_none() + && !is_never_sync_key(key) + && document.lines[*index] + .entry + .as_ref() + .is_some_and(|entry| { + validate_raw_key_value( + key, + &entry.value, + ) + .is_ok() + }) + }) + .count(); + if recognized + custom == 0 { + eligible = false; + disabled_reason = Some(if version_supported { + GameOptionsSourceIssue::NoSyncableSettings + } else { + GameOptionsSourceIssue::UnsupportedVersion + }); + } else { + recognized_setting_count = recognized as u32; + custom_setting_count = custom as u32; + } + } + Err(error) => { + tracing::warn!( + "Could not inspect {} as a game-settings source: {error}", + metadata.instance.id + ); + eligible = false; + disabled_reason = + Some(GameOptionsSourceIssue::UnreadableOptionsFile); + } + } + } + sources.push(GameOptionsSourceCandidate { + source_id: metadata.instance.id.clone(), + instance_id: metadata.instance.id, + name: metadata.instance.name, + icon_path: metadata.instance.icon_path, + game_version: Some(metadata.applied_content_set.game_version), + eligible, + disabled_reason, + recognized_setting_count, + custom_setting_count, + }); + } + sources.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then(left.instance_id.cmp(&right.instance_id)) + }); + Ok(sources) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/write_shared_settings.rs b/packages/app-lib/src/api/instance/synced_options/game_options/write_shared_settings.rs new file mode 100644 index 0000000..b781fcd --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/write_shared_settings.rs @@ -0,0 +1,393 @@ +//! Imports `options.txt` saves and copies canonical values to synced instances. + +use super::super as synced_options; +use super::CATALOG_REVISION; +use super::api_types::{ + GameOptionMappingKind, SaveGameSettingsResult, SyncOutcome, +}; +use super::catalog::*; +use super::options_file::{ + GameOptionsDocument, options_path, read_document, sha1_bytes, +}; +use super::pack_updates::materialize_yosbr_options_if_missing; +use super::read_instance_changes::read_instance_changes_into_shared_settings; +use super::source_selection::initialize_from_source_instance; +use crate::state::{ + CanonicalValue, InstanceMetadata, State, SyncedOption, + game_options_sync_is_enabled, load_game_option_preferences, + load_game_options_sync_state, load_shared_game_options, +}; +use crate::util::io; + +struct AppliedSettings { + count: usize, + used_migration: bool, +} + +pub(super) async fn sync_is_active_for_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + Ok(metadata.synced_options.game_options + && !synced_options::pending::contains( + &metadata.instance.id, + SyncedOption::GameOptions, + state, + ) + .await? + && game_options_sync_is_enabled(&state.pool).await?) +} + +pub(super) async fn remember_processed_sha( + instance_id: &str, + sha1: &str, + state: &State, +) -> crate::Result<()> { + let (canonical_revision, _) = + load_game_options_sync_state(&state.pool, CATALOG_REVISION).await?; + synced_options::begin_checkpoint( + instance_id, + SyncedOption::GameOptions, + "default", + sha1, + None, + canonical_revision as i64, + state, + ) + .await?; + synced_options::finish_plain_checkpoint( + instance_id, + SyncedOption::GameOptions, + "default", + state, + ) + .await +} + +async fn apply_shared_settings_to_document( + metadata: &InstanceMetadata, + document: &mut GameOptionsDocument, + state: &State, +) -> crate::Result { + let values = load_shared_game_options(&state.pool).await?; + let preferences = load_game_option_preferences(&state.pool).await?; + let mut count = 0; + let mut used_migration = false; + + for (option_id, preference) in preferences { + if !preference.enabled { + continue; + } + if option_id == "fullscreen" + && metadata.launch_overrides.force_fullscreen.is_some() + { + continue; + } + let Some(stored) = values.get(&option_id) else { + continue; + }; + let Some(value) = stored.value.as_ref().filter(|_| stored.seeded) + else { + continue; + }; + let definition = setting_by_id(&option_id); + if definition.is_some() + && !supported_settings_cover_game_version( + &metadata.applied_content_set.game_version, + ) + { + continue; + } + if definition.is_none() + && stored + .raw_key + .as_deref() + .is_some_and(|key| setting_by_file_key(key).is_some()) + { + continue; + } + + let target_version = &metadata.applied_content_set.game_version; + let key = + target_physical_key(definition, stored, document, target_version) + .or_else(|| { + definition.and_then(|definition| { + target_key_for_version(definition, target_version) + .map(str::to_string) + }) + }) + .or_else(|| stored.raw_key.clone()); + let Some(key) = key else { + continue; + }; + + let migrated = definition.is_some_and(|definition| { + alias_migration_needed(definition, document, &key) + || document.value(&key).is_some_and(|raw| { + !physical_representation_supported_for_target( + definition, + &key, + raw, + target_version, + ) + }) + }); + let raw_value = if let Some(definition) = definition { + let Some(raw_value) = encode_value( + definition, + &key, + value, + target_version, + document.value(&key), + ) else { + continue; + }; + raw_value + } else if let CanonicalValue::ExternalRaw(raw_value) = value { + raw_value.clone() + } else { + continue; + }; + + document.set(&key, &raw_value, true)?; + count += 1; + used_migration |= migrated + || definition.is_some_and(|definition| { + (matches!(definition.encoding, ValueEncoding::KeyBinding) + && split_key_binding(&raw_value).0.parse::().is_ok()) + || physical_variant_for_present_key( + definition, + &key, + target_version, + ) + .map(|variant| { + variant.mapping != GameOptionMappingKind::Direct + }) + .unwrap_or_else(|| { + definition.keys.first().copied() != Some(key.as_str()) + }) + }); + } + + Ok(AppliedSettings { + count, + used_migration, + }) +} + +pub(super) async fn apply_shared_settings_to_instance( + metadata: &InstanceMetadata, + state: &State, + allow_protected: bool, +) -> crate::Result { + if !allow_protected && synced_options::sync_files_are_protected(metadata) { + return Ok(SyncOutcome::Deferred); + } + if !sync_is_active_for_instance(metadata, state).await? { + return Ok(SyncOutcome::Unchanged); + } + + let path = options_path(metadata, state)?; + if !path.exists() { + materialize_yosbr_options_if_missing(metadata, state).await?; + } + let file_was_missing = !path.exists(); + let (mut document, input_bytes) = if path.exists() { + read_document(&path).await? + } else { + let Some(document) = + GameOptionsDocument::for_instance(metadata, state).await? + else { + return Ok(SyncOutcome::WaitingForFile); + }; + (document, Vec::new()) + }; + if document.value("version").is_none() + && !supported_settings_cover_game_version( + &metadata.applied_content_set.game_version, + ) + { + return Ok(SyncOutcome::Deferred); + } + let applied = + apply_shared_settings_to_document(metadata, &mut document, state) + .await?; + if file_was_missing && applied.count == 0 { + return Ok(SyncOutcome::WaitingForFile); + } + + let output_bytes = document.serialize()?; + let output_sha1 = sha1_bytes(&output_bytes); + let changed = file_was_missing || output_bytes != input_bytes; + + // Register the expected bytes before touching the file. The filesystem + // watcher can observe the write immediately on some platforms; recording + // the checkpoint first prevents that event from being misclassified as a + // user edit and importing stale data back into the shared store. + synced_options::begin_checkpoint( + &metadata.instance.id, + SyncedOption::GameOptions, + "default", + &output_sha1, + None, + load_game_options_sync_state(&state.pool, CATALOG_REVISION) + .await? + .0 as i64, + state, + ) + .await?; + if changed { + io::write(&path, output_bytes).await?; + } + synced_options::finish_plain_checkpoint( + &metadata.instance.id, + SyncedOption::GameOptions, + "default", + state, + ) + .await?; + + Ok(if changed && applied.used_migration { + SyncOutcome::Migrated + } else if changed { + SyncOutcome::Applied + } else { + SyncOutcome::Unchanged + }) +} + +pub(super) async fn capture_instance_options( + metadata: &InstanceMetadata, + state: &State, + allow_protected: bool, +) -> crate::Result { + if !allow_protected && synced_options::sync_files_are_protected(metadata) { + return Ok(SyncOutcome::Deferred); + } + if !sync_is_active_for_instance(metadata, state).await? { + return Ok(SyncOutcome::Unchanged); + } + + let path = options_path(metadata, state)?; + if !path.exists() { + return Ok(SyncOutcome::WaitingForFile); + } + let (document, input_bytes) = read_document(&path).await?; + let input_sha1 = sha1_bytes(&input_bytes); + let checkpoint = synced_options::checkpoint( + &metadata.instance.id, + SyncedOption::GameOptions, + "default", + state, + ) + .await?; + if checkpoint + .as_ref() + .is_some_and(|checkpoint| checkpoint.expected_sha1 == input_sha1) + { + return Ok(SyncOutcome::Unchanged); + } + + let canonical_changed = + if crate::state::shared_game_options_exist(&state.pool).await? { + read_instance_changes_into_shared_settings( + metadata, &document, state, + ) + .await? + } else { + initialize_from_source_instance(metadata, state).await?; + true + }; + remember_processed_sha(&metadata.instance.id, &input_sha1, state).await?; + + if canonical_changed { + let _ = sync_other_participating_instances( + state, + Some(&metadata.instance.id), + ) + .await; + } + Ok(SyncOutcome::Unchanged) +} + +pub(in crate::api::instance) async fn capture_instance_file_change( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + capture_instance_options(metadata, state, false).await?; + Ok(()) +} + +pub(in crate::api::instance) async fn apply_instance_with_state( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + apply_shared_settings_to_instance(metadata, state, false).await?; + Ok(()) +} + +pub(super) async fn sync_other_participating_instances( + state: &State, + excluded_instance_id: Option<&str>, +) -> SaveGameSettingsResult { + let mut result = SaveGameSettingsResult { + state: None, + applied: 0, + migrated: 0, + deferred: 0, + unsupported: 0, + failed: 0, + conflicts: Vec::new(), + }; + match game_options_sync_is_enabled(&state.pool).await { + Ok(true) => {} + Ok(false) => return result, + Err(error) => { + tracing::warn!( + "Failed to inspect global game-options state before fan-out: {error}" + ); + result.failed += 1; + return result; + } + } + let instances = match crate::state::list_instances(&state.pool).await { + Ok(instances) => instances, + Err(error) => { + tracing::warn!( + "Failed to list instances for game-options fan-out: {error}" + ); + result.failed += 1; + return result; + } + }; + for metadata in instances { + if excluded_instance_id == Some(metadata.instance.id.as_str()) + || !metadata.synced_options.game_options + { + continue; + } + match apply_shared_settings_to_instance(&metadata, state, false).await { + Ok(SyncOutcome::Applied | SyncOutcome::Unchanged) => { + result.applied += 1 + } + Ok(SyncOutcome::Migrated) => result.migrated += 1, + Ok(SyncOutcome::Deferred | SyncOutcome::WaitingForFile) => { + result.deferred += 1 + } + Err(error) => { + tracing::warn!( + "Failed to write synced game options into {}: {error}", + metadata.instance.id + ); + result.failed += 1; + } + } + } + result +} + +pub(crate) async fn sync_all_participating_instances( + state: &State, +) -> SaveGameSettingsResult { + sync_other_participating_instances(state, None).await +} diff --git a/packages/app-lib/src/api/instance/synced_options/hotbars.rs b/packages/app-lib/src/api/instance/synced_options/hotbars.rs new file mode 100644 index 0000000..1112d0e --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/hotbars.rs @@ -0,0 +1,603 @@ +use super::HOTBAR_FILE; +use super::files::{ + CheckpointStatus, begin_checkpoint, checkpoint, ensure_link, + finish_checkpoint, instance_game_dir, nbt_from_bytes, nbt_to_bytes, + read_nbt_file, safe_instance_id, sha1_bytes, sha1_file, + synced_options_path, +}; +use super::orchestration::{ + backup_bytes, create_synced_directories, option_effective, + seed_from_instance, +}; +use crate::state::{InstanceMetadata, SyncedOption}; +use crate::util::io; +use crate::{ErrorKind, State}; +use quartz_nbt::{NbtCompound, NbtList, NbtTag}; +use std::path::PathBuf; + +const COMPONENTS_DATA_VERSION_FLOOR: i32 = 3837; +pub(super) const HOTBAR_SCHEMA_VERSION: i64 = 2; + +pub(super) struct HotbarState { + pub schema_version: i64, + pub revision: i64, + pub nbt: NbtCompound, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum HotbarFamily { + Legacy, + Components, +} + +impl HotbarFamily { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Legacy => "legacy", + Self::Components => "components", + } + } + + fn other(self) -> Self { + match self { + Self::Legacy => Self::Components, + Self::Components => Self::Legacy, + } + } +} + +pub(super) async fn hotbar_family( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let (manifest, version_index) = + crate::launcher::resolve_minecraft_manifest( + &metadata.applied_content_set.game_version, + state, + ) + .await?; + let cutoff = manifest + .versions + .iter() + .find(|item| item.id == "1.20.5") + .ok_or_else(|| { + ErrorKind::LauncherError( + "Minecraft 1.20.5 is missing from the version manifest" + .to_string(), + ) + })?; + Ok( + if manifest.versions[version_index].release_time >= cutoff.release_time + { + HotbarFamily::Components + } else { + HotbarFamily::Legacy + }, + ) +} + +pub(super) async fn backup_shared_hotbars(state: &State) -> crate::Result<()> { + let sync_state = read_hotbar_state(state).await?; + let legacy = hotbar_family_root(&sync_state.nbt, HotbarFamily::Legacy); + let components = + hotbar_family_root(&sync_state.nbt, HotbarFamily::Components); + backup_bytes( + "shared", + "hotbar-legacy.nbt", + &nbt_to_bytes(&legacy)?, + state, + ) + .await?; + backup_bytes( + "shared", + "hotbar-components.nbt", + &nbt_to_bytes(&components)?, + state, + ) + .await?; + backup_bytes( + "shared", + "hotbars-state.nbt", + &nbt_to_bytes(&sync_state.nbt)?, + state, + ) + .await +} + +pub(super) async fn ensure_hotbar( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + create_synced_directories(state).await?; + if !hotbar_state_exists(state).await? { + seed_from_instance(metadata, SyncedOption::CreativeHotbars, state) + .await?; + } + write_hotbar_projection(metadata, state).await +} + +pub(super) async fn reconcile_hotbar( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if !option_effective(metadata, SyncedOption::CreativeHotbars, state).await? + { + return Ok(()); + } + let local = instance_game_dir(metadata, state)?.join(HOTBAR_FILE); + if !local.exists() { + return ensure_hotbar(metadata, state).await; + } + let family = hotbar_family(metadata, state).await?; + let current_checkpoint = checkpoint( + &metadata.instance.id, + SyncedOption::CreativeHotbars, + family.as_str(), + state, + ) + .await?; + if current_checkpoint + .as_ref() + .is_some_and(|value| value.status == CheckpointStatus::Pending) + { + return write_hotbar_projection(metadata, state).await; + } + let actual = sha1_file(&local).await?; + let mut sync_state = read_hotbar_state(state).await?; + if current_checkpoint + .as_ref() + .map(|value| value.expected_sha1.as_str()) + == Some(actual.as_str()) + { + if current_checkpoint + .as_ref() + .is_some_and(|value| value.source_revision == sync_state.revision) + { + return Ok(()); + } + return write_hotbar_projection(metadata, state).await; + } + + let (changed_family, checkpoint) = if current_checkpoint.is_none() { + let previous_family = family.other(); + let previous_checkpoint = checkpoint( + &metadata.instance.id, + SyncedOption::CreativeHotbars, + previous_family.as_str(), + state, + ) + .await?; + if let Some(previous_checkpoint) = previous_checkpoint { + if previous_checkpoint.status == CheckpointStatus::Pending + || previous_checkpoint.expected_sha1 == actual + { + return write_hotbar_projection(metadata, state).await; + } + (previous_family, Some(previous_checkpoint)) + } else { + (family, None) + } + } else { + (family, current_checkpoint) + }; + + let changed = read_nbt_file(&local).await?; + let merge_base = checkpoint + .and_then(|value| value.merge_base) + .map(nbt_from_bytes) + .transpose()? + .unwrap_or_else(|| hotbar_family_root(&sync_state.nbt, changed_family)); + if merge_hotbar_family( + &mut sync_state.nbt, + changed_family, + &merge_base, + &changed, + ) { + increment_hotbar_revision(&mut sync_state); + write_hotbar_state(state, &sync_state).await?; + } + regenerate_hotbars(state).await +} + +pub(super) async fn instance_hotbars_differ_from_synced( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let local_path = instance_game_dir(metadata, state)?.join(HOTBAR_FILE); + if !local_path.exists() { + return Ok(false); + } + let local = read_nbt_file(&local_path).await?; + let family = hotbar_family(metadata, state).await?; + let synced = + hotbar_family_root(&read_hotbar_state(state).await?.nbt, family); + Ok((0..81) + .any(|slot| hotbar_slot(&local, slot) != hotbar_slot(&synced, slot))) +} + +pub(super) fn merge_hotbar_family( + state: &mut NbtCompound, + family: HotbarFamily, + merge_base: &NbtCompound, + changed: &NbtCompound, +) -> bool { + let family_key = family_state_key(family); + let mut current = state + .get::<_, &NbtCompound>(family_key) + .ok() + .cloned() + .unwrap_or_else(empty_hotbar_root); + let other_family = match family { + HotbarFamily::Legacy => HotbarFamily::Components, + HotbarFamily::Components => HotbarFamily::Legacy, + }; + let other_key = family_state_key(other_family); + let other = state.get::<_, &NbtCompound>(other_key).ok().cloned(); + let seed_components_with_legacy = + other.is_none() && family == HotbarFamily::Legacy; + let mut other_root = if seed_components_with_legacy { + (*changed).clone() + } else { + other.unwrap_or_else(empty_hotbar_root) + }; + let mut revisions = state + .get::<_, &NbtCompound>("Revisions") + .ok() + .cloned() + .unwrap_or_default(); + let family_versions_key = family_data_versions_key(family); + let other_versions_key = family_data_versions_key(other_family); + let mut family_versions = state + .get::<_, &NbtCompound>(family_versions_key) + .ok() + .cloned() + .unwrap_or_default(); + let mut other_versions = state + .get::<_, &NbtCompound>(other_versions_key) + .ok() + .cloned() + .unwrap_or_default(); + let writer_data_version = hotbar_data_version(changed).max(match family { + HotbarFamily::Legacy => 1, + HotbarFamily::Components => COMPONENTS_DATA_VERSION_FLOOR, + }); + let current_data_version = hotbar_data_version(¤t); + let other_data_version = hotbar_data_version(&other_root); + let mut changed_any = false; + + for slot in 0..81 { + let old_slot = hotbar_slot(merge_base, slot); + let new_slot = hotbar_slot(changed, slot); + if old_slot == new_slot { + continue; + } + let current_origin = family_versions + .get::<_, i32>(&slot.to_string()) + .unwrap_or(current_data_version); + if writer_data_version > 0 + && current_origin > 0 + && writer_data_version < current_origin + { + continue; + } + set_hotbar_slot_optional(&mut current, slot, new_slot.clone()); + family_versions.insert(slot.to_string(), writer_data_version); + let revision = revisions + .get::<_, i64>(&slot.to_string()) + .unwrap_or(0) + .saturating_add(1); + revisions.insert(slot.to_string(), revision); + if seed_components_with_legacy { + other_versions.insert(slot.to_string(), writer_data_version); + } else if let Some(slot_value) = new_slot + .and_then(|value| convert_hotbar_slot(value, family, other_family)) + { + let other_origin = other_versions + .get::<_, i32>(&slot.to_string()) + .unwrap_or(other_data_version); + if writer_data_version <= 0 + || other_origin <= 0 + || writer_data_version >= other_origin + { + set_hotbar_slot(&mut other_root, slot, slot_value); + other_versions.insert(slot.to_string(), writer_data_version); + } + } + changed_any = true; + } + + if writer_data_version > current_data_version { + current.insert("DataVersion", writer_data_version); + changed_any = true; + } + if seed_components_with_legacy + && writer_data_version > hotbar_data_version(&other_root) + { + other_root.insert("DataVersion", writer_data_version); + } + + state.insert(family_key, current); + state.insert(other_key, other_root); + state.insert("Revisions", revisions); + state.insert(family_versions_key, family_versions); + state.insert(other_versions_key, other_versions); + changed_any +} + +fn convert_hotbar_slot( + value: NbtTag, + from: HotbarFamily, + to: HotbarFamily, +) -> Option { + if from == to { + return Some(value); + } + let NbtTag::Compound(item) = value else { + return None; + }; + if item.is_empty() { + return Some(NbtCompound::new().into()); + } + let id = item.get::<_, &str>("id").ok()?.to_string(); + + match (from, to) { + (HotbarFamily::Legacy, HotbarFamily::Components) => { + if item.contains_key("tag") { + return None; + } + let count = item + .get::<_, i8>("Count") + .map(i32::from) + .or_else(|_| item.get::<_, i32>("Count")) + .unwrap_or(1); + let mut converted = NbtCompound::new(); + converted.insert("id", id); + converted.insert("count", count); + Some(converted.into()) + } + (HotbarFamily::Components, HotbarFamily::Legacy) => { + if item + .get::<_, &NbtCompound>("components") + .is_ok_and(|components| !components.is_empty()) + { + return None; + } + let count = item.get::<_, i32>("count").unwrap_or(1); + let count = i8::try_from(count).ok()?; + let mut converted = NbtCompound::new(); + converted.insert("id", id); + converted.insert("Count", count); + Some(converted.into()) + } + _ => Some(item.into()), + } +} + +fn hotbar_slot(root: &NbtCompound, slot: usize) -> Option { + let toolbar = (slot / 9).to_string(); + let position = slot % 9; + root.get::<_, &NbtList>(&toolbar) + .ok() + .and_then(|list| list.as_ref().get(position).cloned()) +} + +fn set_hotbar_slot(root: &mut NbtCompound, slot: usize, value: NbtTag) { + let toolbar = (slot / 9).to_string(); + let position = slot % 9; + let mut list = root + .get::<_, &NbtList>(&toolbar) + .ok() + .cloned() + .unwrap_or_default(); + while list.len() < 9 { + list.push(NbtCompound::new()); + } + list[position] = value; + root.insert(toolbar, list); +} + +fn set_hotbar_slot_optional( + root: &mut NbtCompound, + slot: usize, + value: Option, +) { + set_hotbar_slot( + root, + slot, + value.unwrap_or_else(|| NbtCompound::new().into()), + ); +} + +fn hotbar_data_version(root: &NbtCompound) -> i32 { + root.get::<_, i32>("DataVersion").unwrap_or(0) +} + +fn family_data_versions_key(family: HotbarFamily) -> &'static str { + match family { + HotbarFamily::Legacy => "LegacyDataVersions", + HotbarFamily::Components => "ComponentsDataVersions", + } +} + +pub(super) async fn regenerate_hotbars(state: &State) -> crate::Result<()> { + let instances = crate::state::list_instances(&state.pool).await?; + for metadata in instances { + if option_effective(&metadata, SyncedOption::CreativeHotbars, state) + .await? + { + write_hotbar_projection(&metadata, state).await?; + } + } + Ok(()) +} + +async fn write_hotbar_projection( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let sync_state = read_hotbar_state(state).await?; + let family = hotbar_family(metadata, state).await?; + let root = hotbar_family_root(&sync_state.nbt, family); + let bytes = nbt_to_bytes(&root)?; + let expected = sha1_bytes(&bytes); + let generated = generated_hotbar_path(state, family, &metadata.instance.id); + begin_checkpoint( + &metadata.instance.id, + SyncedOption::CreativeHotbars, + family.as_str(), + &expected, + Some(&bytes), + sync_state.revision, + state, + ) + .await?; + if let Some(parent) = generated.parent() { + io::create_dir_all(parent).await?; + } + io::write(&generated, &bytes).await?; + let local = instance_game_dir(metadata, state)?.join(HOTBAR_FILE); + let mode = ensure_link(&generated, &local).await?; + finish_checkpoint( + &metadata.instance.id, + SyncedOption::CreativeHotbars, + family.as_str(), + mode, + state, + ) + .await?; + remove_other_hotbar_checkpoint(&metadata.instance.id, family, state).await +} + +async fn remove_other_hotbar_checkpoint( + instance_id: &str, + family: HotbarFamily, + state: &State, +) -> crate::Result<()> { + let variant = family.as_str(); + sqlx::query( + "DELETE FROM instance_sync_checkpoints + WHERE instance_id = ? + AND feature = 'creative_hotbars' + AND variant != ?", + ) + .bind(instance_id) + .bind(variant) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub(super) fn empty_hotbar_root() -> NbtCompound { + let mut root = NbtCompound::new(); + for toolbar in 0..9 { + let mut items = NbtList::new(); + for _ in 0..9 { + items.push(NbtCompound::new()); + } + root.insert(toolbar.to_string(), items); + } + root +} + +pub(super) async fn read_hotbar_state( + state: &State, +) -> crate::Result { + let row = sqlx::query( + "SELECT schema_version, revision, nbt + FROM synced_hotbar_state + WHERE singleton = 1", + ) + .fetch_optional(&state.pool) + .await?; + if let Some(row) = row { + use sqlx::Row; + let schema_version: i64 = row.get("schema_version"); + let revision: i64 = row.get("revision"); + let nbt: Vec = row.get("nbt"); + return Ok(HotbarState { + schema_version, + revision, + nbt: nbt_from_bytes(nbt)?, + }); + } + Ok(HotbarState { + schema_version: HOTBAR_SCHEMA_VERSION, + revision: 0, + nbt: NbtCompound::new(), + }) +} + +pub(super) async fn hotbar_state_exists(state: &State) -> crate::Result { + Ok(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM synced_hotbar_state WHERE singleton = 1 + )", + ) + .fetch_one(&state.pool) + .await?) +} + +pub(super) fn hotbar_family_root( + state: &NbtCompound, + family: HotbarFamily, +) -> NbtCompound { + let legacy = state + .get::<_, &NbtCompound>("Legacy") + .ok() + .cloned() + .unwrap_or_else(empty_hotbar_root); + match family { + HotbarFamily::Legacy => legacy, + HotbarFamily::Components => state + .get::<_, &NbtCompound>("Components") + .ok() + .cloned() + .unwrap_or(legacy), + } +} + +pub(super) fn increment_hotbar_revision(state: &mut HotbarState) { + state.schema_version = HOTBAR_SCHEMA_VERSION; + state.revision = state.revision.saturating_add(1); +} + +pub(super) async fn write_hotbar_state( + state: &State, + hotbar_state: &HotbarState, +) -> crate::Result<()> { + let bytes = nbt_to_bytes(&hotbar_state.nbt)?; + sqlx::query( + "INSERT INTO synced_hotbar_state + (singleton, schema_version, revision, nbt) + VALUES (1, ?, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + schema_version = excluded.schema_version, + revision = excluded.revision, + nbt = excluded.nbt", + ) + .bind(hotbar_state.schema_version) + .bind(hotbar_state.revision) + .bind(bytes) + .execute(&state.pool) + .await?; + Ok(()) +} + +fn family_state_key(family: HotbarFamily) -> &'static str { + match family { + HotbarFamily::Legacy => "Legacy", + HotbarFamily::Components => "Components", + } +} + +pub(super) fn generated_hotbar_path( + state: &State, + family: HotbarFamily, + instance_id: &str, +) -> PathBuf { + synced_options_path(state) + .join("hotbars/generated") + .join(family.as_str()) + .join(safe_instance_id(instance_id)) + .join(HOTBAR_FILE) +} diff --git a/packages/app-lib/src/api/instance/synced_options/mod.rs b/packages/app-lib/src/api/instance/synced_options/mod.rs new file mode 100644 index 0000000..14b61bc --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/mod.rs @@ -0,0 +1,33 @@ +mod command_history; +mod files; +pub(crate) mod game_options; +mod hotbars; +mod orchestration; +pub(in crate::api::instance) mod pending; + +const COMMAND_HISTORY_FILE: &str = "command_history.txt"; +const HOTBAR_FILE: &str = "hotbar.nbt"; + +pub use self::command_history::{get_command_history, set_command_history}; +pub(super) use self::files::{ + CheckpointStatus, begin_checkpoint, checkpoint, detach_link, ensure_link, + finish_checkpoint, finish_plain_checkpoint, instance_game_dir, + instance_is_running, instance_option_enabled, nbt_from_bytes, nbt_to_bytes, + option_can_apply_while_running, read_nbt_file, safe_instance_id, + sha1_bytes, sha1_file, sync_files_are_protected, synced_options_path, +}; +pub(super) use self::orchestration::instance_option_supported; +pub use self::orchestration::{ + GlobalSyncedOptions, SyncedOptionCapability, SyncedOptionJoinAction, + SyncedOptionJoinPreview, SyncedOptionsOverview, get_capabilities, + get_global_options, get_initialized_options, + get_instance_option_join_preview, get_overview, get_synced_options_folder, + reconcile_all, reconcile_changed_file, reconcile_instance, + set_global_option, set_instance_option, +}; +pub(crate) use self::orchestration::{ + monitor_persisted_processes, prepare_instance_update, + reconcile_instance_after_pack_update_with_state, + remove_generated_instance_files, +}; +pub use self::pending::SyncedOptionJoinResolution; diff --git a/packages/app-lib/src/api/instance/synced_options/orchestration.rs b/packages/app-lib/src/api/instance/synced_options/orchestration.rs new file mode 100644 index 0000000..12f5b32 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/orchestration.rs @@ -0,0 +1,1624 @@ +use crate::state::instances::adapters::sqlite::instance_rows; +use crate::state::{InstanceLink, InstanceMetadata, SyncedOption}; +use crate::util::io; +use crate::{ErrorKind, State}; +use quartz_nbt::NbtCompound; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use std::future::Future; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::super::{synced_packs, synced_servers}; +use super::command_history::{ + command_history_path, ensure_command_history, + merge_command_history_from_instance, normalize_command_history, + reconcile_command_history, +}; +use super::files::{ + begin_checkpoint, detach_link, instance_game_dir, instance_is_running, + instance_option_enabled, option_can_apply_while_running, read_nbt_file, + safe_instance_id, sync_files_are_protected, synced_options_path, +}; +use super::hotbars::{ + HOTBAR_SCHEMA_VERSION, HotbarState, backup_shared_hotbars, + empty_hotbar_root, ensure_hotbar, generated_hotbar_path, hotbar_family, + hotbar_state_exists, increment_hotbar_revision, + instance_hotbars_differ_from_synced, merge_hotbar_family, + read_hotbar_state, reconcile_hotbar, regenerate_hotbars, + write_hotbar_state, +}; +use super::pending::{ + self, PendingAction, PendingChange, SyncedOptionJoinResolution, +}; +use super::{COMMAND_HISTORY_FILE, HOTBAR_FILE}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncedOptionJoinAction { + SeedShared, + Attach, + Merge, + RequiresResolution, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct SyncedOptionJoinPreview { + pub action: SyncedOptionJoinAction, +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] +pub struct GlobalSyncedOptions { + pub game_options: bool, + pub command_history: bool, + pub multiplayer_servers: bool, + pub creative_hotbars: bool, + pub screenshots: bool, + #[serde(default)] + pub resource_packs: bool, + #[serde(default)] + pub data_packs: bool, +} + +impl GlobalSyncedOptions { + pub fn get(self, option: SyncedOption) -> bool { + if !option.is_available() { + return false; + } + match option { + SyncedOption::GameOptions => self.game_options, + SyncedOption::CommandHistory => self.command_history, + SyncedOption::MultiplayerServers => self.multiplayer_servers, + SyncedOption::CreativeHotbars => self.creative_hotbars, + SyncedOption::Screenshots => self.screenshots, + SyncedOption::ResourcePacks => self.resource_packs, + SyncedOption::DataPacks => self.data_packs, + } + } + + fn set(&mut self, option: SyncedOption, enabled: bool) { + let enabled = enabled && option.is_available(); + match option { + SyncedOption::GameOptions => self.game_options = enabled, + SyncedOption::CommandHistory => self.command_history = enabled, + SyncedOption::MultiplayerServers => { + self.multiplayer_servers = enabled + } + SyncedOption::CreativeHotbars => self.creative_hotbars = enabled, + SyncedOption::Screenshots => self.screenshots = enabled, + SyncedOption::ResourcePacks => self.resource_packs = enabled, + SyncedOption::DataPacks => self.data_packs = enabled, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SyncedOptionCapability { + pub option: SyncedOption, + pub supported: bool, + pub disabled_reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SyncedOptionsOverview { + pub global_options: GlobalSyncedOptions, + pub capabilities: Vec, +} + +enum CapabilityStatus { + Supported, + Unsupported(String), + Indeterminate(String), +} + +pub async fn get_global_options() -> crate::Result { + let state = State::get().await?; + get_global_options_with_state(&state).await +} + +pub async fn get_initialized_options() -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let mut options = GlobalSyncedOptions::default(); + + for option in SyncedOption::ALL { + options.set(option, canonical_exists(option, &state).await?); + } + + Ok(options) +} + +pub async fn get_synced_options_folder() -> crate::Result { + let state = State::get().await?; + create_synced_directories(&state).await?; + Ok(synced_options_path(&state)) +} + +async fn get_global_options_with_state( + state: &State, +) -> crate::Result { + let mut options = GlobalSyncedOptions::default(); + let rows = sqlx::query( + "SELECT feature, globally_enabled + FROM sync_feature_settings", + ) + .fetch_all(&state.pool) + .await?; + + use sqlx::Row; + for row in rows { + let feature: String = row.get("feature"); + let globally_enabled: bool = row.get("globally_enabled"); + if let Some(option) = option_from_str(&feature) { + options.set(option, globally_enabled); + } + } + + Ok(options) +} + +pub async fn get_overview( + instance_id: &str, +) -> crate::Result { + let state = State::get().await?; + let global_options = get_global_options_with_state(&state).await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + let mut capabilities = Vec::with_capacity(SyncedOption::ALL.len()); + + for option in SyncedOption::ALL { + capabilities.push( + capability(&metadata, option, global_options.get(option), &state) + .await, + ); + } + + Ok(SyncedOptionsOverview { + global_options, + capabilities, + }) +} + +pub async fn get_capabilities( + instance_id: &str, +) -> crate::Result> { + Ok(get_overview(instance_id).await?.capabilities) +} + +async fn capability( + metadata: &InstanceMetadata, + option: SyncedOption, + global_enabled: bool, + state: &State, +) -> SyncedOptionCapability { + let status = + capability_status(metadata, option, global_enabled, state).await; + let (supported, disabled_reason) = match status { + CapabilityStatus::Supported => (true, None), + CapabilityStatus::Unsupported(reason) + | CapabilityStatus::Indeterminate(reason) => (false, Some(reason)), + }; + + SyncedOptionCapability { + option, + supported, + disabled_reason, + } +} + +async fn capability_status( + metadata: &InstanceMetadata, + option: SyncedOption, + global_enabled: bool, + state: &State, +) -> CapabilityStatus { + if !option.is_available() { + return CapabilityStatus::Unsupported( + "Data pack syncing is currently disabled.".to_string(), + ); + } + if !global_enabled { + return CapabilityStatus::Unsupported( + "This option is disabled in the app's synced options settings." + .to_string(), + ); + } + if option == SyncedOption::MultiplayerServers + && is_linked_server_project(&metadata.link) + { + return CapabilityStatus::Unsupported( + "Multiplayer server syncing is unavailable for linked server-project instances." + .to_string(), + ); + } + match version_capability(metadata, option, state).await { + CapabilityStatus::Supported => CapabilityStatus::Supported, + status => status, + } +} + +pub(in crate::api::instance) async fn instance_option_supported( + metadata: &InstanceMetadata, + option: SyncedOption, + global_enabled: bool, + state: &State, +) -> bool { + if pending::contains(&metadata.instance.id, option, state) + .await + .unwrap_or(true) + { + return false; + } + matches!( + capability_status(metadata, option, global_enabled, state).await, + CapabilityStatus::Supported + ) +} + +async fn version_capability( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> CapabilityStatus { + if matches!( + option, + SyncedOption::GameOptions + | SyncedOption::Screenshots + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks + ) { + return CapabilityStatus::Supported; + } + + let game_version = &metadata.applied_content_set.game_version; + let Ok((manifest, version_index)) = + crate::launcher::resolve_minecraft_manifest(game_version, state).await + else { + return CapabilityStatus::Indeterminate( + "This instance’s Minecraft version could not be verified, so syncing is unavailable." + .to_string(), + ); + }; + let cutoff_id = match option { + SyncedOption::GameOptions + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks => unreachable!(), + // Mojang's manifest does not include Beta 1.8 Pre-release as a + // separate entry, so b1.8 is the first resolvable version at that + // boundary. + SyncedOption::MultiplayerServers => "b1.8", + SyncedOption::CreativeHotbars => "1.12", + SyncedOption::CommandHistory => "1.20.2", + SyncedOption::Screenshots => return CapabilityStatus::Supported, + }; + let Some(cutoff) = + manifest.versions.iter().find(|item| item.id == cutoff_id) + else { + return CapabilityStatus::Indeterminate( + "This instance’s Minecraft version could not be verified, so syncing is unavailable." + .to_string(), + ); + }; + + let version = &manifest.versions[version_index]; + let release_only_option = matches!( + option, + SyncedOption::CreativeHotbars | SyncedOption::CommandHistory + ); + let is_supported_release = + matches!(&version.type_, daedalus::minecraft::VersionType::Release); + + if version.release_time >= cutoff.release_time + && (!release_only_option || is_supported_release) + { + return CapabilityStatus::Supported; + } + + CapabilityStatus::Unsupported( + match option { + SyncedOption::GameOptions | SyncedOption::ResourcePacks | SyncedOption::DataPacks => unreachable!(), + SyncedOption::MultiplayerServers => { + "Multiplayer server syncing requires Minecraft Beta 1.8 Pre-release or newer." + } + SyncedOption::CreativeHotbars => { + "Saved creative hotbars require Minecraft 1.12 or newer." + } + SyncedOption::CommandHistory => { + "Command history syncing requires Minecraft 1.20.2 or newer." + } + SyncedOption::Screenshots => unreachable!(), + } + .to_string(), + ) +} + +pub async fn set_global_option( + option: SyncedOption, + enabled: bool, + base_instance_id: Option<&str>, +) -> crate::Result { + if !option.is_available() { + return Err(ErrorKind::InputError( + "Data pack syncing is currently disabled.".to_string(), + ) + .into()); + } + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + pending::cancel(option, None, &state).await?; + set_global_option_with_state(option, enabled, base_instance_id, &state) + .await +} + +async fn set_global_option_with_state( + option: SyncedOption, + enabled: bool, + base_instance_id: Option<&str>, + state: &State, +) -> crate::Result { + if enabled + && option != SyncedOption::Screenshots + && let Some(base_instance_id) = base_instance_id + { + if option == SyncedOption::GameOptions { + let source = + crate::state::get_instance(base_instance_id, &state.pool) + .await? + .ok_or_else(|| { + ErrorKind::InputError( + "Unknown sync source instance.".to_string(), + ) + })?; + match capability_status(&source, option, true, state).await { + CapabilityStatus::Supported => {} + CapabilityStatus::Unsupported(reason) + | CapabilityStatus::Indeterminate(reason) => { + return Err(ErrorKind::InputError(reason).into()); + } + } + if sync_files_are_protected(&source) { + return queue_source(&source, option, state).await; + } + seed_from_instance(&source, option, state).await?; + complete_pending_source(base_instance_id, option, state).await?; + } else { + return enable_global_option_from_base( + option, + base_instance_id, + state, + ) + .await; + } + } + + // Always persist the global flag and propagate the instance-level default. + // The game-options base-instance path seeds the shared values above, but + // must still enable the feature for every existing instance; otherwise + // only the selected source participates and all other instance toggles + // remain disabled. + set_global_option_enabled(option, enabled, state).await?; + + // Reload metadata after updating the global preference. The initial list + // may contain stale per-instance sync flags, which would otherwise make + // the reconciliation pass skip instances that were just enabled. + let instances = match crate::state::list_instances(&state.pool).await { + Ok(instances) => instances, + Err(error) if option == SyncedOption::GameOptions => { + tracing::warn!( + "Game-settings sync changed, but participating instances could not be listed yet: {error}" + ); + return get_global_options_with_state(state).await; + } + Err(error) => return Err(error), + }; + for metadata in instances { + if pending::contains(&metadata.instance.id, option, state).await? { + continue; + } + if !enabled + && matches!( + option, + SyncedOption::ResourcePacks | SyncedOption::DataPacks + ) + { + detach_option(&metadata, option, state).await?; + continue; + } + let running = !option_can_apply_while_running(option) + && instance_is_running(&metadata, state).await?; + if sync_files_are_protected(&metadata) || running { + continue; + } + let result = if !instance_option_enabled(&metadata, option) { + detach_option(&metadata, option, state).await + } else { + match capability_status(&metadata, option, enabled, state).await { + CapabilityStatus::Supported => { + reconcile_option(&metadata, option, state).await + } + CapabilityStatus::Unsupported(_) => { + detach_option(&metadata, option, state).await + } + CapabilityStatus::Indeterminate(_) => Ok(()), + } + }; + if let Err(error) = result { + if option == SyncedOption::GameOptions { + tracing::warn!( + "Game-settings sync changed, but {} could not be reconciled yet: {error}", + metadata.instance.id + ); + continue; + } + return Err(error); + } + } + + get_global_options_with_state(state).await +} + +async fn set_global_option_enabled( + option: SyncedOption, + enabled: bool, + state: &State, +) -> crate::Result<()> { + set_global_option_enabled_in_pool(option, enabled, &state.pool).await +} + +async fn set_global_option_enabled_in_pool( + option: SyncedOption, + enabled: bool, + pool: &SqlitePool, +) -> crate::Result<()> { + let option_name = option.as_str(); + let mut transaction = pool.begin().await?; + sqlx::query( + " + INSERT INTO sync_feature_settings + (feature, globally_enabled, new_instance_default) + VALUES (?, ?, ?) + ON CONFLICT(feature) DO UPDATE SET + globally_enabled = excluded.globally_enabled, + new_instance_default = excluded.new_instance_default + ", + ) + .bind(option_name) + .bind(enabled) + .bind(enabled) + .execute(&mut *transaction) + .await?; + // Upsert every current instance instead of assuming an older migration or + // import populated the complete instance/feature matrix. An UPDATE alone + // silently leaves instances without a preference row disabled. + sqlx::query( + "INSERT INTO instance_sync_preferences (instance_id, feature, enabled) + SELECT id, ?, ? FROM instances + WHERE 1 + ON CONFLICT(instance_id, feature) DO UPDATE SET enabled = excluded.enabled", + ) + .bind(option_name) + .bind(enabled) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(()) +} +async fn enable_global_option_from_base( + option: SyncedOption, + base_instance_id: &str, + state: &State, +) -> crate::Result { + let source = crate::state::get_instance(base_instance_id, &state.pool) + .await? + .ok_or_else(|| { + ErrorKind::InputError("Unknown sync source instance.".to_string()) + })?; + match capability_status(&source, option, true, state).await { + CapabilityStatus::Supported => {} + CapabilityStatus::Unsupported(reason) + | CapabilityStatus::Indeterminate(reason) => { + return Err(ErrorKind::InputError(reason).into()); + } + } + if sync_files_are_protected(&source) + || (!option_can_apply_while_running(option) + && instance_is_running(&source, state).await?) + { + return queue_source(&source, option, state).await; + } + + if matches!( + option, + SyncedOption::ResourcePacks | SyncedOption::DataPacks + ) { + seed_from_instance(&source, option, state).await?; + complete_pending_source(base_instance_id, option, state).await?; + instance_rows::set_instance_sync_preference( + base_instance_id, + option, + true, + &state.pool, + ) + .await?; + set_global_option_enabled(option, true, state).await?; + for metadata in crate::state::list_instances(&state.pool).await? { + if sync_files_are_protected(&metadata) { + continue; + } + if instance_option_enabled(&metadata, option) { + synced_packs::reconcile(&metadata, option, state).await?; + } + } + return get_global_options_with_state(state).await; + } + + let instances = crate::state::list_instances(&state.pool).await?; + for metadata in &instances { + if metadata.instance.id != base_instance_id + && pending::contains(&metadata.instance.id, option, state).await? + { + continue; + } + if !instance_option_enabled(metadata, option) { + continue; + } + let installing = sync_files_are_protected(metadata); + if installing + || (!option_can_apply_while_running(option) + && instance_is_running(metadata, state).await?) + { + defer_option(metadata, option, state).await?; + if option == SyncedOption::CommandHistory && !installing { + detach_option(metadata, option, state).await?; + } + continue; + } + detach_option(metadata, option, state).await?; + } + if !instance_option_enabled(&source, option) { + detach_option(&source, option, state).await?; + } + + seed_from_instance(&source, option, state).await?; + complete_pending_source(base_instance_id, option, state).await?; + instance_rows::set_instance_sync_preference( + base_instance_id, + option, + true, + &state.pool, + ) + .await?; + set_global_option_enabled(option, true, state).await?; + + for metadata in crate::state::list_instances(&state.pool).await? { + if pending::contains(&metadata.instance.id, option, state).await? { + continue; + } + if sync_files_are_protected(&metadata) + || (!option_can_apply_while_running(option) + && instance_is_running(&metadata, state).await?) + { + continue; + } + if !instance_option_enabled(&metadata, option) { + detach_option(&metadata, option, state).await?; + continue; + } + match capability_status(&metadata, option, true, state).await { + CapabilityStatus::Supported => { + ensure_option(&metadata, option, state).await? + } + CapabilityStatus::Unsupported(_) => { + detach_option(&metadata, option, state).await? + } + CapabilityStatus::Indeterminate(_) => {} + } + } + + get_global_options_with_state(state).await +} + +async fn complete_pending_source( + instance_id: &str, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + pending::remove( + &PendingChange { + instance_id: instance_id.to_string(), + option, + action: PendingAction::SelectSource, + }, + state, + ) + .await +} + +async fn queue_source( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result { + pending::save( + PendingChange { + instance_id: metadata.instance.id.clone(), + option, + action: PendingAction::SelectSource, + }, + state, + ) + .await?; + instance_rows::set_instance_sync_preference( + &metadata.instance.id, + option, + true, + &state.pool, + ) + .await?; + set_global_option_enabled(option, true, state).await?; + get_global_options_with_state(state).await +} + +/// Pending checkpoints apply shared data without capturing the old local file. +async fn defer_option( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + let variants: &[&str] = match option { + SyncedOption::CommandHistory | SyncedOption::MultiplayerServers => { + &["default"] + } + SyncedOption::CreativeHotbars => &["legacy", "components"], + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + return synced_packs::detach(metadata, option, state).await; + } + SyncedOption::GameOptions | SyncedOption::Screenshots => return Ok(()), + }; + for variant in variants { + begin_checkpoint( + &metadata.instance.id, + option, + variant, + "", + None, + 0, + state, + ) + .await?; + } + Ok(()) +} + +pub async fn set_instance_option( + instance_id: &str, + option: SyncedOption, + enabled: bool, + resolution: Option, +) -> crate::Result { + if !option.is_available() { + return Err(ErrorKind::InputError( + "Data pack syncing is currently disabled.".to_string(), + ) + .into()); + } + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + let previous_enabled = instance_option_enabled(&metadata, option); + // A global toggle can update this preference before the local projection + // is created. Re-run the enable path instead of returning early solely + // because the preference already says enabled. + // Disabling is intentionally idempotent: the persisted preference may + // already be false while an old projection or pending operation still + // needs to be detached. + let global = get_global_options_with_state(&state).await?; + let can_reconcile = !sync_files_are_protected(&metadata) + && (option_can_apply_while_running(option) + || !instance_is_running(&metadata, &state).await?); + if enabled { + let eligibility = + capability(&metadata, option, global.get(option), &state).await; + if !eligibility.supported { + return Err(ErrorKind::InputError( + eligibility.disabled_reason.unwrap_or_default(), + ) + .into()); + } + let action = + instance_option_join_action(&metadata, option, &state).await?; + if action == SyncedOptionJoinAction::RequiresResolution + && resolution.is_none() + { + return Err(ErrorKind::InputError( + "Choose whether to use the synced hotbars or this instance's hotbars." + .to_string(), + ) + .into()); + } + if can_reconcile { + pending::cancel(option, Some(instance_id), &state).await?; + prepare_instance_join(&metadata, option, resolution, &state) + .await?; + } else { + pending::save( + PendingChange { + instance_id: instance_id.to_string(), + option, + action: PendingAction::Join { resolution }, + }, + &state, + ) + .await?; + } + } else { + pending::cancel(option, Some(instance_id), &state).await?; + } + + instance_rows::set_instance_sync_preference( + instance_id, + option, + enabled, + &state.pool, + ) + .await?; + // The projection code must see the preference that was just persisted. + // In particular, game-settings reconciliation checks + // `metadata.synced_options` before applying anything, so using the stale + // pre-write metadata would turn a successful enable into a no-op. + let updated_metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + if can_reconcile { + let result = if enabled { + ensure_option(&updated_metadata, option, &state).await + } else { + detach_option(&updated_metadata, option, &state).await + }; + if let Err(error) = result { + if option == SyncedOption::GameOptions + && let Err(rollback_error) = + instance_rows::set_instance_sync_preference( + instance_id, + option, + previous_enabled, + &state.pool, + ) + .await + { + tracing::error!( + "Failed to roll back the game-settings sync preference for {instance_id}: {rollback_error}" + ); + } + return Err(error); + } + } else if !enabled + && matches!( + option, + SyncedOption::ResourcePacks | SyncedOption::DataPacks + ) + { + detach_option(&updated_metadata, option, &state).await?; + } + + Ok(updated_metadata) +} + +pub async fn get_instance_option_join_preview( + instance_id: &str, + option: SyncedOption, +) -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + let global = get_global_options_with_state(&state).await?; + let eligibility = + capability(&metadata, option, global.get(option), &state).await; + if !eligibility.supported { + return Err(ErrorKind::InputError( + eligibility.disabled_reason.unwrap_or_default(), + ) + .into()); + } + + Ok(SyncedOptionJoinPreview { + action: instance_option_join_action(&metadata, option, &state).await?, + }) +} + +async fn instance_option_join_action( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result { + if !canonical_exists(option, state).await? { + return Ok(SyncedOptionJoinAction::SeedShared); + } + Ok(match option { + SyncedOption::GameOptions => SyncedOptionJoinAction::Attach, + SyncedOption::CommandHistory | SyncedOption::MultiplayerServers => { + SyncedOptionJoinAction::Merge + } + SyncedOption::CreativeHotbars => { + if sync_files_are_protected(metadata) + || instance_is_running(metadata, state).await? + || instance_hotbars_differ_from_synced(metadata, state).await? + { + SyncedOptionJoinAction::RequiresResolution + } else { + SyncedOptionJoinAction::Attach + } + } + SyncedOption::Screenshots + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks => SyncedOptionJoinAction::Attach, + }) +} + +async fn prepare_instance_join( + metadata: &InstanceMetadata, + option: SyncedOption, + resolution: Option, + state: &State, +) -> crate::Result<()> { + let action = instance_option_join_action(metadata, option, state).await?; + backup_instance_option_file(metadata, option, state).await?; + match action { + SyncedOptionJoinAction::SeedShared => { + seed_from_instance(metadata, option, state).await?; + } + SyncedOptionJoinAction::Attach => {} + SyncedOptionJoinAction::Merge => match option { + SyncedOption::CommandHistory => { + merge_command_history_from_instance(metadata, state).await?; + } + SyncedOption::MultiplayerServers => { + synced_servers::merge_servers_from_instance(metadata, state) + .await?; + } + _ => unreachable!(), + }, + SyncedOptionJoinAction::RequiresResolution => { + if resolution == Some(SyncedOptionJoinResolution::UseInstance) { + backup_shared_hotbars(state).await?; + seed_from_instance(metadata, option, state).await?; + } + } + } + defer_option(metadata, option, state).await +} + +async fn apply_pending_changes(state: &State) -> crate::Result<()> { + for change in pending::read(state).await? { + if !pending::read(state).await?.contains(&change) { + continue; + } + let Some(metadata) = + crate::state::get_instance(&change.instance_id, &state.pool) + .await? + else { + pending::remove(&change, state).await?; + continue; + }; + let global = get_global_options_with_state(state).await?; + if !global.get(change.option) + || !instance_option_enabled(&metadata, change.option) + { + pending::remove(&change, state).await?; + continue; + } + if sync_files_are_protected(&metadata) + || (!option_can_apply_while_running(change.option) + && instance_is_running(&metadata, state).await?) + { + continue; + } + let result = match change.action { + PendingAction::SelectSource => set_global_option_with_state( + change.option, + true, + Some(&change.instance_id), + state, + ) + .await + .map(|_| ()), + PendingAction::Join { resolution } => { + if !matches!( + capability_status(&metadata, change.option, true, state) + .await, + CapabilityStatus::Supported + ) { + continue; + } + match prepare_instance_join( + &metadata, + change.option, + resolution, + state, + ) + .await + { + Ok(()) => { + pending::remove(&change, state).await?; + ensure_option(&metadata, change.option, state).await + } + Err(error) => Err(error), + } + } + }; + if let Err(error) = result { + tracing::warn!( + "Could not apply pending {:?} sync change for {}: {error}", + change.option, + change.instance_id, + ); + } + } + Ok(()) +} + +pub async fn reconcile_all() -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + create_synced_directories(&state).await?; + apply_pending_changes(&state).await?; + let instances = crate::state::list_instances(&state.pool).await?; + for metadata in instances { + if let Err(error) = + reconcile_instance_with_state(&metadata, &state, false).await + { + tracing::warn!( + "Failed to reconcile synced options for {}: {error}", + metadata.instance.id + ); + } + } + Ok(()) +} + +pub(crate) async fn monitor_persisted_processes() -> crate::Result<()> { + let state = State::get().await?; + let instance_ids = sqlx::query_scalar::<_, String>( + "SELECT DISTINCT instance_id FROM processes", + ) + .fetch_all(&state.pool) + .await?; + for instance_id in instance_ids { + tokio::spawn(async move { + loop { + let Ok(state) = State::get().await else { + break; + }; + let Ok(Some(metadata)) = + crate::state::get_instance(&instance_id, &state.pool).await + else { + break; + }; + match instance_is_running(&metadata, &state).await { + Ok(true) => { + tokio::time::sleep(std::time::Duration::from_secs(5)) + .await; + } + Ok(false) => { + if let Err(error) = + reconcile_instance(&instance_id).await + { + tracing::warn!( + "Failed to reconcile synced options after closing {instance_id}: {error}" + ); + } + break; + } + Err(error) => { + tracing::warn!( + "Failed to inspect the persisted process for {instance_id}: {error}" + ); + tokio::time::sleep(std::time::Duration::from_secs(5)) + .await; + } + } + } + }); + } + Ok(()) +} + +/// Keeps reconciliation state on the heap so callers do not inherit its size. +pub fn reconcile_instance( + instance_id: &str, +) -> impl Future> + Send + '_ { + Box::pin(reconcile_instance_inner(instance_id)) +} + +async fn reconcile_instance_inner(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + apply_pending_changes(&state).await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + reconcile_instance_with_state(&metadata, &state, false).await +} + +pub(crate) async fn reconcile_instance_after_pack_update_with_state( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + reconcile_instance_with_state(&metadata, state, true).await +} + +pub(crate) async fn prepare_instance_update( + instance_id: &str, +) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + if let Err(error) = + synced_packs::prepare_instance_update(&metadata, &state).await + { + tracing::warn!( + "The modpack update will continue, but resource-pack selection could not be prepared: {error}" + ); + } + for option in [ + SyncedOption::GameOptions, + SyncedOption::CommandHistory, + SyncedOption::CreativeHotbars, + SyncedOption::MultiplayerServers, + ] { + if option == SyncedOption::GameOptions { + if let Err(error) = + super::game_options::prepare_instance_update_with_state( + &metadata, &state, + ) + .await + { + tracing::warn!( + "The modpack update will continue, but options.txt could not be prepared for game-settings sync: {error}" + ); + } + continue; + } + if instance_option_enabled(&metadata, option) { + detach_option(&metadata, option, &state).await?; + } + } + Ok(()) +} + +async fn reconcile_instance_with_state( + metadata: &InstanceMetadata, + state: &State, + allow_protected: bool, +) -> crate::Result<()> { + if !allow_protected && sync_files_are_protected(metadata) { + return Ok(()); + } + let running = instance_is_running(metadata, state).await?; + let global = get_global_options_with_state(state).await?; + for option in SyncedOption::ALL { + if pending::contains(&metadata.instance.id, option, state).await? { + continue; + } + if running && !option_can_apply_while_running(option) { + continue; + } + if !instance_option_enabled(metadata, option) { + detach_option(metadata, option, state).await?; + continue; + } + match capability_status(metadata, option, global.get(option), state) + .await + { + CapabilityStatus::Supported => { + if option == SyncedOption::GameOptions { + continue; + } + let result = reconcile_option(metadata, option, state).await; + if let Err(error) = result { + if option == SyncedOption::GameOptions { + tracing::warn!( + "Game settings could not be reconciled for {}, continuing with the remaining synced options: {error}", + metadata.instance.id + ); + } else { + return Err(error); + } + } + } + CapabilityStatus::Unsupported(_) => { + detach_option(metadata, option, state).await? + } + CapabilityStatus::Indeterminate(_) => {} + } + } + Ok(()) +} + +async fn reconcile_option( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + match option { + SyncedOption::GameOptions => { + super::game_options::apply_instance_with_state(metadata, state) + .await + } + SyncedOption::CommandHistory => { + reconcile_command_history(metadata, state).await + } + SyncedOption::CreativeHotbars => { + reconcile_hotbar(metadata, state).await + } + SyncedOption::MultiplayerServers => { + synced_servers::reconcile_servers(metadata, state).await + } + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + synced_packs::reconcile(metadata, option, state).await + } + SyncedOption::Screenshots => Ok(()), + } +} + +pub async fn reconcile_changed_file( + instance_id: &str, + file_name: &str, +) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + apply_pending_changes(&state).await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + if sync_files_are_protected(&metadata) { + return Ok(()); + } + match file_name { + "options.txt" => { + let participates = option_participates( + &metadata, + SyncedOption::GameOptions, + &state, + ) + .await?; + if participates { + super::game_options::capture_instance_file_change( + &metadata, &state, + ) + .await?; + } + let resource_packs_participate = option_participates( + &metadata, + SyncedOption::ResourcePacks, + &state, + ) + .await?; + if resource_packs_participate { + synced_packs::capture_resource_pack_selection_change( + &metadata, &state, + ) + .await?; + } + Ok(()) + } + COMMAND_HISTORY_FILE => { + reconcile_command_history(&metadata, &state).await + } + HOTBAR_FILE => reconcile_hotbar(&metadata, &state).await, + "servers.dat" => { + synced_servers::reconcile_servers(&metadata, &state).await + } + "resourcepacks" => { + let option = SyncedOption::ResourcePacks; + if !option_participates(&metadata, option, &state).await? { + return Ok(()); + } + synced_packs::seed_from_instance(&metadata, option, &state).await?; + for target in crate::state::list_instances(&state.pool).await? { + if !sync_files_are_protected(&target) + && option_participates(&target, option, &state).await? + { + synced_packs::reconcile(&target, option, &state).await?; + } + } + Ok(()) + } + "datapacks" => { + let option = SyncedOption::DataPacks; + if !option_participates(&metadata, option, &state).await? { + return Ok(()); + } + synced_packs::seed_from_instance(&metadata, option, &state).await?; + for target in crate::state::list_instances(&state.pool).await? { + if !sync_files_are_protected(&target) + && option_participates(&target, option, &state).await? + { + synced_packs::reconcile(&target, option, &state).await?; + } + } + Ok(()) + } + _ => Ok(()), + } +} + +pub(crate) async fn remove_generated_instance_files( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + let instance_id = safe_instance_id(instance_id); + for path in [ + synced_options_path(state) + .join("hotbars/generated/legacy") + .join(&instance_id), + synced_options_path(state) + .join("hotbars/generated/components") + .join(&instance_id), + synced_options_path(state) + .join("servers/generated") + .join(&instance_id), + synced_options_path(state) + .join("backups") + .join(&instance_id), + ] { + if path.exists() { + io::remove_dir_all(path).await?; + } + } + Ok(()) +} + +pub(super) async fn create_synced_directories( + state: &State, +) -> crate::Result<()> { + for path in [ + synced_options_path(state), + synced_options_path(state).join("hotbars/generated/legacy"), + synced_options_path(state).join("hotbars/generated/components"), + synced_options_path(state).join("servers/generated"), + ] { + io::create_dir_all(path).await?; + } + Ok(()) +} + +async fn backup_instance_option_file( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + let directory = instance_game_dir(metadata, state)?; + let path = match option { + SyncedOption::GameOptions => directory.join("options.txt"), + SyncedOption::CommandHistory => directory.join(COMMAND_HISTORY_FILE), + SyncedOption::CreativeHotbars => directory.join(HOTBAR_FILE), + SyncedOption::MultiplayerServers => directory.join("servers.dat"), + SyncedOption::Screenshots + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks => return Ok(()), + }; + if !path.exists() { + return Ok(()); + } + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) + else { + return Ok(()); + }; + backup_bytes( + &metadata.instance.id, + file_name, + &io::read(&path).await?, + state, + ) + .await +} + +pub(super) async fn backup_bytes( + owner: &str, + file_name: &str, + contents: &[u8], + state: &State, +) -> crate::Result<()> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let directory = synced_options_path(state) + .join("backups") + .join(safe_instance_id(owner)) + .join(timestamp.to_string()); + io::create_dir_all(&directory).await?; + io::write(directory.join(file_name), contents).await?; + Ok(()) +} + +pub(super) async fn seed_from_instance( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + create_synced_directories(state).await?; + let instance_dir = instance_game_dir(metadata, state)?; + match option { + SyncedOption::GameOptions => { + super::game_options::initialize_from_source_instance( + metadata, state, + ) + .await?; + } + SyncedOption::CommandHistory => { + let path = instance_dir.join(COMMAND_HISTORY_FILE); + let contents = if path.exists() { + String::from_utf8_lossy(&io::read(path).await?).into_owned() + } else { + String::new() + }; + io::write( + command_history_path(state), + normalize_command_history(&contents), + ) + .await?; + } + SyncedOption::CreativeHotbars => { + let path = instance_dir.join(HOTBAR_FILE); + let family = hotbar_family(metadata, state).await?; + let root = if path.exists() { + read_nbt_file(&path).await? + } else { + empty_hotbar_root() + }; + let previous_state = read_hotbar_state(state).await?; + let mut sync_state = HotbarState { + schema_version: HOTBAR_SCHEMA_VERSION, + revision: previous_state.revision, + nbt: NbtCompound::new(), + }; + let merge_base = empty_hotbar_root(); + if merge_hotbar_family( + &mut sync_state.nbt, + family, + &merge_base, + &root, + ) { + increment_hotbar_revision(&mut sync_state); + } + write_hotbar_state(state, &sync_state).await?; + regenerate_hotbars(state).await?; + } + SyncedOption::MultiplayerServers => { + synced_servers::seed_servers(metadata, state).await?; + } + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + synced_packs::seed_from_instance(metadata, option, state).await?; + } + SyncedOption::Screenshots => {} + } + Ok(()) +} + +async fn ensure_option( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + match option { + SyncedOption::GameOptions => { + super::game_options::apply_instance_with_state(metadata, state) + .await + } + SyncedOption::CommandHistory => { + ensure_command_history(metadata, state).await + } + SyncedOption::CreativeHotbars => ensure_hotbar(metadata, state).await, + SyncedOption::MultiplayerServers => { + synced_servers::ensure_servers(metadata, state).await + } + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + synced_packs::reconcile(metadata, option, state).await + } + SyncedOption::Screenshots => Ok(()), + } +} + +async fn detach_option( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + let instance_dir = instance_game_dir(metadata, state)?; + match option { + SyncedOption::GameOptions => { + super::game_options::detach_instance(metadata, state).await + } + SyncedOption::CommandHistory => { + detach_link( + &command_history_path(state), + &instance_dir.join(COMMAND_HISTORY_FILE), + ) + .await + } + SyncedOption::CreativeHotbars => { + let target = instance_dir.join(HOTBAR_FILE); + let family = hotbar_family(metadata, state).await.ok(); + let source = family + .map(|family| { + generated_hotbar_path(state, family, &metadata.instance.id) + }) + .unwrap_or_else(|| target.clone()); + detach_link(&source, &target).await + } + SyncedOption::MultiplayerServers => { + synced_servers::detach_servers(metadata, state).await + } + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + synced_packs::detach(metadata, option, state).await + } + SyncedOption::Screenshots => Ok(()), + } +} + +async fn canonical_exists( + option: SyncedOption, + state: &State, +) -> crate::Result { + Ok(match option { + SyncedOption::GameOptions => { + super::game_options::canonical_exists(state).await? + } + SyncedOption::CommandHistory => command_history_path(state).exists(), + SyncedOption::CreativeHotbars => hotbar_state_exists(state).await?, + SyncedOption::MultiplayerServers => { + synced_servers::canonical_exists(state).await? + } + SyncedOption::Screenshots + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks => true, + }) +} + +pub(super) async fn option_effective( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result { + if sync_files_are_protected(metadata) + || instance_is_running(metadata, state).await? + { + return Ok(false); + } + option_participates(metadata, option, state).await +} + +async fn option_participates( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result { + if pending::contains(&metadata.instance.id, option, state).await? { + return Ok(false); + } + let global = get_global_options_with_state(state).await?; + Ok(instance_option_enabled(metadata, option) + && capability(metadata, option, global.get(option), state) + .await + .supported) +} + +fn is_linked_server_project(link: &InstanceLink) -> bool { + matches!( + link, + InstanceLink::ServerProject { .. } + | InstanceLink::ServerProjectModpack { .. } + ) +} + +fn option_from_str(value: &str) -> Option { + match value { + "game_options" => Some(SyncedOption::GameOptions), + "command_history" => Some(SyncedOption::CommandHistory), + "multiplayer_servers" => Some(SyncedOption::MultiplayerServers), + "creative_hotbars" => Some(SyncedOption::CreativeHotbars), + "screenshots" => Some(SyncedOption::Screenshots), + "resource_packs" => Some(SyncedOption::ResourcePacks), + "data_packs" => Some(SyncedOption::DataPacks), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + #[tokio::test] + async fn global_toggle_upserts_every_instance_preference() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::raw_sql( + r#" + CREATE TABLE instances (id TEXT PRIMARY KEY NOT NULL); + CREATE TABLE sync_feature_settings ( + feature TEXT PRIMARY KEY NOT NULL, + globally_enabled INTEGER NOT NULL, + new_instance_default INTEGER NOT NULL + ); + CREATE TABLE instance_sync_preferences ( + instance_id TEXT NOT NULL, + feature TEXT NOT NULL, + enabled INTEGER NOT NULL, + PRIMARY KEY (instance_id, feature) + ); + INSERT INTO instances (id) VALUES ('one'), ('two'); + INSERT INTO sync_feature_settings + (feature, globally_enabled, new_instance_default) + VALUES ('multiplayer_servers', 0, 0); + INSERT INTO instance_sync_preferences + (instance_id, feature, enabled) + VALUES ('one', 'multiplayer_servers', 0); + "#, + ) + .execute(&pool) + .await + .unwrap(); + + set_global_option_enabled_in_pool( + SyncedOption::MultiplayerServers, + true, + &pool, + ) + .await + .unwrap(); + + let global: (bool, bool) = sqlx::query_as( + "SELECT globally_enabled, new_instance_default + FROM sync_feature_settings WHERE feature = 'multiplayer_servers'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(global, (true, true)); + + let preferences: Vec<(String, bool)> = sqlx::query_as( + "SELECT instance_id, enabled FROM instance_sync_preferences + WHERE feature = 'multiplayer_servers' ORDER BY instance_id", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + preferences, + vec![("one".to_string(), true), ("two".to_string(), true)] + ); + + set_global_option_enabled_in_pool( + SyncedOption::MultiplayerServers, + false, + &pool, + ) + .await + .unwrap(); + let enabled_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM instance_sync_preferences + WHERE feature = 'multiplayer_servers' AND enabled = 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(enabled_count, 0); + } +} diff --git a/packages/app-lib/src/api/instance/synced_options/pending.rs b/packages/app-lib/src/api/instance/synced_options/pending.rs new file mode 100644 index 0000000..739c888 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/pending.rs @@ -0,0 +1,104 @@ +use super::files::synced_options_path; +use crate::State; +use crate::state::SyncedOption; +use crate::util::io; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncedOptionJoinResolution { + UseSynced, + UseInstance, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub(super) enum PendingAction { + SelectSource, + Join { + resolution: Option, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub(super) struct PendingChange { + pub instance_id: String, + pub option: SyncedOption, + pub action: PendingAction, +} + +pub(super) async fn read(state: &State) -> crate::Result> { + let path = synced_options_path(state).join("pending-changes.json"); + match tokio::fs::read(path).await { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(Vec::new()) + } + Err(error) => Err(error.into()), + } +} + +async fn write(changes: &[PendingChange], state: &State) -> crate::Result<()> { + let directory = synced_options_path(state); + io::create_dir_all(&directory).await?; + let temporary = directory.join("pending-changes.json.tmp"); + io::write(&temporary, serde_json::to_vec(changes)?).await?; + tokio::fs::rename(temporary, directory.join("pending-changes.json")) + .await?; + Ok(()) +} + +/// Callers hold the synced-options lock while updating or replaying requests. +pub(super) async fn save( + change: PendingChange, + state: &State, +) -> crate::Result<()> { + let mut changes = read(state).await?; + changes.retain(|previous| { + previous.option != change.option + || (change.action != PendingAction::SelectSource + && previous.instance_id != change.instance_id) + }); + changes.push(change); + write(&changes, state).await +} + +pub(super) async fn remove( + change: &PendingChange, + state: &State, +) -> crate::Result<()> { + let mut changes = read(state).await?; + let previous_len = changes.len(); + changes.retain(|previous| previous != change); + if changes.len() != previous_len { + write(&changes, state).await?; + } + Ok(()) +} + +pub(super) async fn cancel( + option: SyncedOption, + instance_id: Option<&str>, + state: &State, +) -> crate::Result<()> { + let mut changes = read(state).await?; + let previous_len = changes.len(); + changes.retain(|change| { + change.option != option + || instance_id.is_some_and(|id| change.instance_id != id) + }); + if changes.len() != previous_len { + write(&changes, state).await?; + } + Ok(()) +} + +pub(in crate::api::instance) async fn contains( + instance_id: &str, + option: SyncedOption, + state: &State, +) -> crate::Result { + Ok(read(state).await?.iter().any(|change| { + change.instance_id == instance_id && change.option == option + })) +} diff --git a/packages/app-lib/src/api/instance/synced_packs_axolotl.rs b/packages/app-lib/src/api/instance/synced_packs_axolotl.rs new file mode 100644 index 0000000..04415d3 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_packs_axolotl.rs @@ -0,0 +1,1135 @@ +//! 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, +} + +#[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 { + 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 { + 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 { + 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 { + 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::().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 { + 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> { + Ok(sqlx::query_scalar::<_, Option>( + "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 { + 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 { + 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::, + version: None::, + owner: None::, + update: None, + date_added: None, + provider_refs: Vec::::new(), + origin_provider: None::, + rollback: None::, + environment: None, + source_kind: Some(ContentSourceKind::SharedInstance), + external: true, + loader: None, + }) +} + +async fn load_row(id: &str, state: &State) -> crate::Result { + 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> { + 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 { + 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, +) -> 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::>(); + + 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::>(); + 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::, _>("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()); + } +} diff --git a/packages/app-lib/src/api/instance/synced_servers/codec.rs b/packages/app-lib/src/api/instance/synced_servers/codec.rs new file mode 100644 index 0000000..7c54960 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/codec.rs @@ -0,0 +1,112 @@ +use super::super::synced_options::{ + nbt_from_bytes, nbt_to_bytes, read_nbt_file, +}; +use crate::ErrorKind; +use quartz_nbt::{NbtCompound, NbtList, NbtTag}; +use std::path::Path; + +pub(crate) async fn read_servers( + path: &Path, +) -> crate::Result> { + if !path.exists() { + return Ok(Vec::new()); + } + servers_from_root(read_nbt_file(path).await?) +} + +pub(super) fn servers_from_bytes( + bytes: Vec, +) -> crate::Result> { + servers_from_root(nbt_from_bytes(bytes)?) +} + +fn servers_from_root(root: NbtCompound) -> crate::Result> { + let list = root.get::<_, &NbtList>("servers").map_err(|_| { + ErrorKind::InputError( + "servers.dat does not contain a valid servers list".to_string(), + ) + })?; + list.iter() + .map(|tag| match tag { + NbtTag::Compound(compound) => Ok(compound.clone()), + _ => Err(ErrorKind::InputError( + "servers.dat contains an invalid server entry".to_string(), + ) + .into()), + }) + .collect() +} + +pub(crate) async fn write_servers( + path: &Path, + servers: &[NbtCompound], +) -> crate::Result<()> { + if let Some(parent) = path.parent() { + crate::util::io::create_dir_all(parent).await?; + } + crate::util::io::write(path, servers_to_bytes(servers)?).await?; + Ok(()) +} + +pub(super) fn servers_to_bytes( + servers: &[NbtCompound], +) -> crate::Result> { + let mut list = NbtList::new(); + for server in servers { + list.push(server.clone()); + } + let mut root = NbtCompound::new(); + root.insert("servers", list); + nbt_to_bytes(&root) +} + +pub(crate) fn server_data( + name: String, + address: String, + accept_textures: Option, +) -> NbtCompound { + let mut server = NbtCompound::new(); + server.insert("name", name); + server.insert("ip", address); + if let Some(accept_textures) = accept_textures { + server.insert("acceptTextures", i8::from(accept_textures)); + } + server.insert("hidden", 0_i8); + server +} + +pub(super) fn update_server_data( + server: &mut NbtCompound, + name: String, + address: String, + accept_textures: Option, +) { + server.insert("name", name); + server.insert("ip", address); + match accept_textures { + Some(value) => server.insert("acceptTextures", i8::from(value)), + None => { + server.inner_mut().remove("acceptTextures"); + } + } +} + +pub(super) fn server_hidden(server: &NbtCompound) -> bool { + server.get::<_, i8>("hidden").unwrap_or(0) != 0 +} + +pub(super) fn server_address(server: &NbtCompound) -> String { + server.get::<_, &str>("ip").unwrap_or_default().to_string() +} + +pub(super) fn server_identity_address(server: &NbtCompound) -> String { + server_address(server).trim().to_ascii_lowercase() +} + +pub(super) fn server_identity_name(server: &NbtCompound) -> String { + server + .get::<_, &str>("name") + .unwrap_or_default() + .trim() + .to_ascii_lowercase() +} diff --git a/packages/app-lib/src/api/instance/synced_servers/mod.rs b/packages/app-lib/src/api/instance/synced_servers/mod.rs new file mode 100644 index 0000000..4f41d6e --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/mod.rs @@ -0,0 +1,24 @@ +mod codec; +mod modpack; +mod operations; +mod storage; +mod types; + +const SERVERS_FILE: &str = "servers.dat"; + +pub(crate) use self::codec::server_data; +pub use self::modpack::{capture_modpack_servers, clear_modpack_servers}; +pub(crate) use self::operations::{ + add_user_server, ensure_managed_server, list_server_records, + remove_server_by_index, update_server_by_index, +}; +pub use self::operations::{ + desync_server, list_synced_servers, remove_synced_server, + update_synced_server, +}; +pub(super) use self::operations::{ + detach_servers, ensure_servers, merge_servers_from_instance, + reconcile_servers, seed_servers, +}; +pub(super) use self::storage::canonical_exists; +pub use self::types::{DesyncServerMode, ServerSource, SyncedServer}; diff --git a/packages/app-lib/src/api/instance/synced_servers/modpack.rs b/packages/app-lib/src/api/instance/synced_servers/modpack.rs new file mode 100644 index 0000000..fdfb0b6 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/modpack.rs @@ -0,0 +1,212 @@ +use super::super::synced_options::instance_game_dir; +use super::SERVERS_FILE; +use super::codec::{read_servers, servers_from_bytes}; +use super::operations::{compose_instance, effective}; +use super::storage::{load_local, write_local_rows}; +use super::types::{LocalServer, ServerSource}; +use crate::state::{CachedEntry, InstanceLink, InstanceMetadata}; +use crate::util::fetch::fetch; +use crate::{ErrorKind, State}; +use async_zip::base::read::seek::ZipFileReader; +use quartz_nbt::NbtCompound; +use std::io::Cursor; +use uuid::Uuid; + +pub async fn capture_modpack_servers(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + let path = instance_game_dir(&metadata, &state)?.join(SERVERS_FILE); + let servers = read_servers(&path).await?; + replace_modpack_servers(&metadata, servers, &state).await?; + if effective(&metadata, &state).await? { + compose_instance(&metadata, &state).await?; + } + Ok(()) +} + +pub async fn clear_modpack_servers(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + replace_modpack_servers(&metadata, Vec::new(), &state).await?; + if effective(&metadata, &state).await? { + compose_instance(&metadata, &state).await?; + } + Ok(()) +} + +async fn replace_modpack_servers( + metadata: &InstanceMetadata, + servers: Vec, + state: &State, +) -> crate::Result<()> { + let mut local = servers + .into_iter() + .enumerate() + .map(|(position, data)| LocalServer { + id: Uuid::new_v4().to_string(), + source: ServerSource::Modpack, + excluded_synced_server_id: None, + data, + position: position as i64, + }) + .collect::>(); + let mut retained = load_local(&metadata.instance.id, state) + .await? + .into_iter() + .filter(|server| server.source != ServerSource::Modpack) + .collect::>(); + for server in &mut retained { + server.position = local.len() as i64; + local.push(server.clone()); + } + let mut tx = state.pool.begin().await?; + write_local_rows(&mut tx, &metadata.instance.id, &local).await?; + let version_id = modpack_version_id(&metadata.link); + sqlx::query( + "INSERT INTO instance_server_pack_state (instance_id, version_id) + VALUES (?, ?) + ON CONFLICT(instance_id) DO UPDATE SET + version_id = excluded.version_id", + ) + .bind(&metadata.instance.id) + .bind(version_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn pack_state_matches_link( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let row = sqlx::query( + "SELECT version_id + FROM instance_server_pack_state + WHERE instance_id = ?", + ) + .bind(&metadata.instance.id) + .fetch_optional(&state.pool) + .await?; + Ok(row.is_some_and(|row| { + use sqlx::Row; + let version_id: Option = row.get("version_id"); + version_id.as_deref() == modpack_version_id(&metadata.link) + })) +} + +pub(super) async fn pack_state_exists( + instance_id: &str, + state: &State, +) -> crate::Result { + Ok(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM instance_server_pack_state WHERE instance_id = ? + )", + ) + .bind(instance_id) + .fetch_one(&state.pool) + .await?) +} + +pub(super) async fn reconstruct_modpack_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let version_id = modpack_version_id(&metadata.link).ok_or_else(|| { + ErrorKind::InputError( + "This modpack does not have a recoverable Modrinth version." + .to_string(), + ) + })?; + let typed_version_id = + crate::state::ModrinthVersionId::new(version_id.to_string())?; + let version = CachedEntry::get_version( + &typed_version_id, + None, + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or_else(|| { + ErrorKind::InputError(format!( + "Modpack version {version_id} could not be found" + )) + })?; + let primary_file = version + .files + .iter() + .find(|file| file.primary) + .or_else(|| version.files.first()) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "Modpack version {version_id} has no downloadable file" + )) + })?; + let mrpack = fetch( + &primary_file.url, + primary_file.hashes.get("sha1").map(String::as_str), + None, + &state.api_semaphore, + &state.pool, + ) + .await?; + let mut archive = ZipFileReader::with_tokio(Cursor::new(&mrpack)).await?; + let mut selected = None; + for (index, entry) in archive.file().entries().iter().enumerate() { + let Ok(filename) = entry.filename().as_str() else { + continue; + }; + let priority = if filename == "client-overrides/servers.dat" { + 2 + } else if filename == "overrides/servers.dat" { + 1 + } else { + continue; + }; + if selected + .is_none_or(|(selected_priority, _)| priority >= selected_priority) + { + selected = Some((priority, index)); + } + } + + let servers = if let Some((_, index)) = selected { + let mut bytes = Vec::new(); + let mut reader = archive.reader_with_entry(index).await?; + reader.read_to_end_checked(&mut bytes).await?; + servers_from_bytes(bytes)? + } else { + Vec::new() + }; + replace_modpack_servers(metadata, servers, state).await +} + +pub(super) fn is_modpack_link(link: &InstanceLink) -> bool { + matches!( + link, + InstanceLink::ModrinthModpack { .. } + | InstanceLink::ServerProjectModpack { .. } + | InstanceLink::ImportedModpack { .. } + ) +} + +fn modpack_version_id(link: &InstanceLink) -> Option<&str> { + match link { + InstanceLink::ModrinthModpack { version_id, .. } => Some(version_id), + InstanceLink::ServerProjectModpack { + content_version_id, .. + } => Some(content_version_id), + InstanceLink::ImportedModpack { + version_id: Some(version_id), + .. + } => Some(version_id), + _ => None, + } +} diff --git a/packages/app-lib/src/api/instance/synced_servers/operations.rs b/packages/app-lib/src/api/instance/synced_servers/operations.rs new file mode 100644 index 0000000..453a852 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/operations.rs @@ -0,0 +1,1037 @@ +use super::super::synced_options::{ + CheckpointStatus, checkpoint, detach_link, ensure_link, finish_checkpoint, + instance_game_dir, instance_is_running, instance_option_enabled, + instance_option_supported, sha1_bytes, sha1_file, sync_files_are_protected, +}; +use crate::state::{InstanceMetadata, SyncedOption}; +use crate::{ErrorKind, State}; +use quartz_nbt::NbtCompound; +use uuid::Uuid; + +use super::SERVERS_FILE; +use super::codec::{ + read_servers, server_address, server_hidden, server_identity_address, + server_identity_name, servers_to_bytes, update_server_data, write_servers, +}; +use super::modpack::{ + is_modpack_link, pack_state_exists, pack_state_matches_link, + reconstruct_modpack_servers, +}; +use super::storage::{ + begin_server_checkpoint, canonical_exists, commit_server_state, + generated_path, load_local, load_projection_entries, read_canonical, + read_server_snapshot, server_revision, write_local, +}; +use super::types::{ + CanonicalServer, DesyncServerMode, LocalServer, ProjectionEntry, + ProjectionOwner, ServerRecord, ServerSource, SyncedServer, +}; +use std::collections::HashSet; + +fn normalized_server_address(data: &NbtCompound) -> (String, u16) { + let address = server_identity_address(data); + let (host, port) = crate::server_address::parse_server_address(&address) + .unwrap_or((&address, 25565)); + (host.trim().to_string(), port) +} + +async fn sync_candidates( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + let locals = load_local(&metadata.instance.id, state).await?; + let mut servers = + read_servers(&instance_game_dir(metadata, state)?.join(SERVERS_FILE)) + .await?; + for local in locals { + let local_address = normalized_server_address(&local.data); + let index = servers + .iter() + .position(|data| data == &local.data) + .or_else(|| { + servers.iter().position(|data| { + !local_address.0.is_empty() + && normalized_server_address(data) == local_address + }) + }); + if let Some(index) = index { + servers.remove(index); + } + } + Ok(servers) +} + +pub(in crate::api::instance) async fn seed_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if is_modpack_link(&metadata.link) + && (!pack_state_matches_link(metadata, state).await? + || !pack_state_exists(&metadata.instance.id, state).await?) + && let Err(error) = reconstruct_modpack_servers(metadata, state).await + { + tracing::warn!( + "Failed to reconstruct the server pack state for {}: {error}", + metadata.instance.id + ); + } + if is_modpack_link(&metadata.link) + && !pack_state_exists(&metadata.instance.id, state).await? + { + return Err(ErrorKind::InputError( + "This linked modpack cannot be used as the multiplayer base until its supplied server list has been reconstructed. Choose an unmanaged instance instead." + .to_string(), + ) + .into()); + } + let servers = sync_candidates(metadata, state).await?; + let canonical = servers + .into_iter() + .map(|data| CanonicalServer { + id: Uuid::new_v4().to_string(), + data, + }) + .collect::>(); + commit_server_state(Some(&canonical), None, state).await?; + regenerate_servers(state).await +} + +pub(in crate::api::instance) async fn merge_servers_from_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if is_modpack_link(&metadata.link) + && (!pack_state_matches_link(metadata, state).await? + || !pack_state_exists(&metadata.instance.id, state).await?) + && let Err(error) = reconstruct_modpack_servers(metadata, state).await + { + tracing::warn!( + "Failed to reconstruct the server pack state for {}: {error}", + metadata.instance.id + ); + } + if is_modpack_link(&metadata.link) + && !pack_state_exists(&metadata.instance.id, state).await? + { + return Err(ErrorKind::InputError( + "This linked modpack cannot join multiplayer syncing until its supplied server list has been reconstructed." + .to_string(), + ) + .into()); + } + let candidates = sync_candidates(metadata, state).await?; + + let mut canonical = read_canonical(state).await?; + for data in candidates { + let address = server_identity_address(&data); + let exists = canonical.iter().any(|server| { + server.data == data + || (!address.is_empty() + && server_identity_address(&server.data) == address) + }); + if !exists { + canonical.push(CanonicalServer { + id: Uuid::new_v4().to_string(), + data, + }); + } + } + if commit_server_state(Some(&canonical), None, state).await? { + regenerate_servers(state).await?; + } + Ok(()) +} + +pub(in crate::api::instance) async fn ensure_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if is_modpack_link(&metadata.link) + && !pack_state_matches_link(metadata, state).await? + && let Err(error) = reconstruct_modpack_servers(metadata, state).await + { + tracing::warn!( + "Failed to reconstruct the server pack state for {}; retaining its last known per-instance server metadata: {error}", + metadata.instance.id + ); + } + if !canonical_exists(state).await? { + if !is_modpack_link(&metadata.link) { + let servers = sync_candidates(metadata, state).await?; + let canonical = servers + .into_iter() + .map(|data| CanonicalServer { + id: Uuid::new_v4().to_string(), + data, + }) + .collect::>(); + commit_server_state(Some(&canonical), None, state).await?; + } else { + commit_server_state(Some(&[]), None, state).await?; + } + } + compose_instance(metadata, state).await +} + +pub(in crate::api::instance) async fn detach_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let generated = generated_path(state, &metadata.instance.id); + let local = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + detach_link(&generated, &local).await +} + +pub(in crate::api::instance) async fn reconcile_servers( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if !effective(metadata, state).await? { + return Ok(()); + } + let local_path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + if !local_path.exists() { + return compose_instance(metadata, state).await; + } + let checkpoint = checkpoint( + &metadata.instance.id, + SyncedOption::MultiplayerServers, + "default", + state, + ) + .await?; + if checkpoint + .as_ref() + .is_some_and(|value| value.status == CheckpointStatus::Pending) + { + return compose_instance(metadata, state).await; + } + let actual = sha1_file(&local_path).await?; + let revision = server_revision(state).await?; + if checkpoint + .as_ref() + .map(|value| value.expected_sha1.as_str()) + == Some(actual.as_str()) + { + if checkpoint + .as_ref() + .is_some_and(|value| value.source_revision == revision) + { + return Ok(()); + } + return compose_instance(metadata, state).await; + } + + let current = read_servers(&local_path).await?; + let projections = + load_projection_entries(&metadata.instance.id, state).await?; + let projection_matches = match_projection_entries(¤t, &projections); + let mut matched = HashSet::new(); + let mut canonical = read_canonical(state).await?; + let mut locals = load_local(&metadata.instance.id, state).await?; + + let mut canonical_order = Vec::new(); + + for ((position, data), projection) in + current.into_iter().enumerate().zip(projection_matches) + { + if let Some(projection) = projection { + matched.insert(projection.id.clone()); + match projection.owner { + ProjectionOwner::Synced => { + canonical_order.push(projection.id.clone()); + if let Some(server) = canonical + .iter_mut() + .find(|server| server.id == projection.id) + && data != projection.data + { + server.data = data; + } + } + ProjectionOwner::Instance => { + if let Some(server) = locals + .iter_mut() + .find(|server| server.id == projection.id) + { + if data != projection.data { + server.data = data; + } + server.position = position as i64; + } + } + } + } else { + let id = Uuid::new_v4().to_string(); + canonical_order.push(id.clone()); + canonical.push(CanonicalServer { id, data }); + } + } + + canonical.retain(|server| { + !projections.iter().any(|projection| { + projection.owner == ProjectionOwner::Synced + && projection.id == server.id + && !matched.contains(&projection.id) + }) + }); + locals.retain(|server| { + !projections.iter().any(|projection| { + projection.owner == ProjectionOwner::Instance + && projection.id == server.id + && !matched.contains(&projection.id) + }) + }); + let by_id = canonical + .iter() + .cloned() + .map(|server| (server.id.clone(), server)) + .collect::>(); + let visible = canonical_order.iter().cloned().collect::>(); + let mut ordered = canonical_order + .into_iter() + .filter_map(|id| by_id.get(&id).cloned()); + for server in &mut canonical { + if visible.contains(&server.id) + && let Some(next) = ordered.next() + { + *server = next; + } + } + let canonical_changed = commit_server_state( + Some(&canonical), + Some((&metadata.instance.id, &locals)), + state, + ) + .await?; + if canonical_changed { + regenerate_servers(state).await + } else { + compose_instance(metadata, state).await + } +} + +fn match_projection_entries<'a>( + current: &[NbtCompound], + projections: &'a [ProjectionEntry], +) -> Vec> { + let mut matches = vec![None; current.len()]; + let mut matched_projections = HashSet::new(); + + for (position, data) in current.iter().enumerate() { + let candidate = projections + .iter() + .enumerate() + .filter(|(index, projection)| { + !matched_projections.contains(index) && projection.data == *data + }) + .min_by_key(|(_, projection)| { + projection.position.abs_diff(position as i64) + }) + .map(|(index, _)| index); + if let Some(index) = candidate { + matches[position] = Some(index); + matched_projections.insert(index); + } + } + + for (position, data) in current.iter().enumerate() { + if matches[position].is_some() { + continue; + } + let address = server_identity_address(data); + if address.is_empty() { + continue; + } + let candidate = projections + .iter() + .enumerate() + .filter(|(index, projection)| { + !matched_projections.contains(index) + && server_identity_address(&projection.data) == address + }) + .min_by_key(|(_, projection)| { + projection.position.abs_diff(position as i64) + }) + .map(|(index, _)| index); + if let Some(index) = candidate { + matches[position] = Some(index); + matched_projections.insert(index); + } + } + + for (position, data) in current.iter().enumerate() { + if matches[position].is_some() { + continue; + } + let name = server_identity_name(data); + if name.is_empty() { + continue; + } + let candidate = projections + .iter() + .enumerate() + .filter(|(index, projection)| { + !matched_projections.contains(index) + && server_identity_name(&projection.data) == name + }) + .min_by_key(|(_, projection)| { + projection.position.abs_diff(position as i64) + }) + .map(|(index, _)| index); + if let Some(index) = candidate { + matches[position] = Some(index); + matched_projections.insert(index); + } + } + + matches + .into_iter() + .map(|projection| projection.map(|index| &projections[index])) + .collect() +} + +#[tracing::instrument(name = "server_records", skip_all, fields(instance_id = %metadata.instance.id))] +pub(crate) async fn list_server_records( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + if participating(metadata, state).await? { + let (canonical, locals) = + read_server_snapshot(&metadata.instance.id, state).await?; + return Ok(merge_server_records(canonical, locals)); + } + list_local_server_records(metadata, state).await +} + +async fn list_server_records_locked( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + if participating(metadata, state).await? { + return compose_records(metadata, state).await; + } + list_local_server_records(metadata, state).await +} + +async fn list_local_server_records( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + Ok( + read_servers(&instance_game_dir(metadata, state)?.join(SERVERS_FILE)) + .await? + .into_iter() + .map(|data| ServerRecord { + id: Uuid::new_v4().to_string(), + source: ServerSource::LocalDesynced, + data, + }) + .collect(), + ) +} + +/// Keeps an automatically added server local, even before server sync is enabled. +pub(crate) async fn ensure_managed_server( + metadata: &InstanceMetadata, + data: NbtCompound, + state: &State, +) -> crate::Result<()> { + let _guard = state.lock_synced_options().await; + let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let syncing = participating(metadata, state).await?; + let records = list_server_records_locked(metadata, state).await?; + let address = normalized_server_address(&data); + let name = server_identity_name(&data); + let existing_index = records + .iter() + .position(|record| { + normalized_server_address(&record.data) == address + && server_identity_name(&record.data) == name + }) + .or_else(|| { + records.iter().position(|record| { + normalized_server_address(&record.data) == address + }) + }); + let existing = existing_index.map(|index| &records[index]); + let position = existing_index.unwrap_or_else(|| { + records + .iter() + .position(ServerRecord::hidden) + .unwrap_or(records.len()) + }); + let data = existing + .map(|record| { + let mut existing_data = record.data.clone(); + if record.hidden() { + existing_data.insert( + "name", + data.get::<_, &str>("name").unwrap_or_default().to_string(), + ); + existing_data.insert("hidden", 0_i8); + } + existing_data + }) + .unwrap_or(data); + let excluded_synced_server_id = existing + .filter(|record| record.source == ServerSource::UserSynced) + .map(|record| record.id.clone()); + let mut locals = load_local(&metadata.instance.id, state).await?; + if let Some(local) = locals + .iter_mut() + .find(|server| normalized_server_address(&server.data) == address) + { + let excluded_synced_server_id = excluded_synced_server_id + .or_else(|| local.excluded_synced_server_id.clone()); + if existing.is_some_and(|record| record.data == data) + && local.data == data + && local.position == position as i64 + && local.excluded_synced_server_id == excluded_synced_server_id + && (!syncing || path.exists()) + { + return Ok(()); + } + local.data = data.clone(); + local.position = position as i64; + local.excluded_synced_server_id = excluded_synced_server_id; + } else { + locals.push(LocalServer { + id: Uuid::new_v4().to_string(), + source: ServerSource::LinkedServerProject, + excluded_synced_server_id, + data: data.clone(), + position: position as i64, + }); + } + write_local(&metadata.instance.id, &locals, state).await?; + if syncing { + if effective(metadata, state).await? { + compose_instance(metadata, state).await?; + } + } else if existing.is_none_or(|record| record.data != data) { + let mut servers = records + .into_iter() + .map(|record| record.data) + .collect::>(); + if let Some(index) = existing_index { + servers[index] = data; + } else { + servers.insert(position, data); + } + write_servers(&path, &servers).await?; + } + Ok(()) +} + +pub(crate) async fn add_user_server( + metadata: &InstanceMetadata, + mut data: NbtCompound, + state: &State, +) -> crate::Result { + let _guard = state.lock_synced_options().await; + let id = Uuid::new_v4().to_string(); + if participating(metadata, state).await? { + let mut canonical = read_canonical(state).await?; + canonical.push(CanonicalServer { + id: id.clone(), + data, + }); + commit_server_state(Some(&canonical), None, state).await?; + regenerate_servers(state).await?; + } else { + let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let mut servers = read_servers(&path).await?; + let insert_index = servers + .iter() + .position(server_hidden) + .unwrap_or(servers.len()); + servers.insert(insert_index, std::mem::take(&mut data)); + write_servers(&path, &servers).await?; + } + Ok(id) +} + +async fn update_server_locked( + metadata: &InstanceMetadata, + server_id: &str, + data: NbtCompound, + state: &State, +) -> crate::Result<()> { + if participating(metadata, state).await? { + let records = compose_records(metadata, state).await?; + let record = records + .iter() + .find(|record| record.id == server_id) + .ok_or_else(|| { + ErrorKind::InputError("Unknown server".to_string()) + })?; + if record.source == ServerSource::UserSynced { + let mut canonical = read_canonical(state).await?; + let server = canonical + .iter_mut() + .find(|server| server.id == server_id) + .ok_or_else(|| { + ErrorKind::InputError("Unknown server".to_string()) + })?; + server.data = data; + commit_server_state(Some(&canonical), None, state).await?; + regenerate_servers(state).await?; + } else { + let mut locals = load_local(&metadata.instance.id, state).await?; + let server = locals + .iter_mut() + .find(|server| server.id == server_id) + .ok_or_else(|| { + ErrorKind::InputError("Unknown server".to_string()) + })?; + server.data = data; + write_local(&metadata.instance.id, &locals, state).await?; + compose_instance(metadata, state).await?; + } + return Ok(()); + } + Err(ErrorKind::InputError( + "Stable server editing requires multiplayer syncing for this instance." + .to_string(), + ) + .into()) +} + +pub(crate) async fn update_server_by_index( + metadata: &InstanceMetadata, + index: usize, + name: String, + address: String, + accept_textures: Option, + state: &State, +) -> crate::Result<()> { + let _guard = state.lock_synced_options().await; + if participating(metadata, state).await? { + let records = list_server_records_locked(metadata, state).await?; + let record = records + .get(index) + .filter(|record| !record.hidden()) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "No editable server at index {index}" + )) + })?; + let mut data = record.data.clone(); + update_server_data(&mut data, name, address, accept_textures); + return update_server_locked(metadata, &record.id, data, state).await; + } + let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let mut servers = read_servers(&path).await?; + let server = servers + .get_mut(index) + .filter(|server| !server_hidden(server)) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "No editable server at index {index}" + )) + })?; + update_server_data(server, name, address, accept_textures); + write_servers(&path, &servers).await +} + +async fn remove_server_locked( + metadata: &InstanceMetadata, + server_id: &str, + state: &State, +) -> crate::Result<()> { + if !participating(metadata, state).await? { + return Err(ErrorKind::InputError( + "Stable server removal requires multiplayer syncing for this instance." + .to_string(), + ) + .into()); + } + let records = compose_records(metadata, state).await?; + let record = records + .iter() + .find(|record| record.id == server_id) + .ok_or_else(|| ErrorKind::InputError("Unknown server".to_string()))?; + if record.source == ServerSource::UserSynced { + let mut canonical = read_canonical(state).await?; + canonical.retain(|server| server.id != server_id); + commit_server_state(Some(&canonical), None, state).await?; + regenerate_servers(state).await + } else { + let mut locals = load_local(&metadata.instance.id, state).await?; + locals.retain(|server| server.id != server_id); + write_local(&metadata.instance.id, &locals, state).await?; + compose_instance(metadata, state).await + } +} + +pub(crate) async fn remove_server_by_index( + metadata: &InstanceMetadata, + index: usize, + state: &State, +) -> crate::Result<()> { + let _guard = state.lock_synced_options().await; + if participating(metadata, state).await? { + let records = list_server_records_locked(metadata, state).await?; + let record = records + .get(index) + .filter(|record| !record.hidden()) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "No removable server at index {index}" + )) + })?; + return remove_server_locked(metadata, &record.id, state).await; + } + let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let mut servers = read_servers(&path).await?; + if servers.get(index).is_none_or(server_hidden) { + return Err(ErrorKind::InputError(format!( + "No removable server at index {index}" + )) + .into()); + } + servers.remove(index); + write_servers(&path, &servers).await +} + +pub async fn desync_server( + instance_id: &str, + server_id: &str, + mode: DesyncServerMode, +) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + if !participating(&metadata, &state).await? { + return Err(ErrorKind::InputError( + "This instance is not participating in multiplayer server syncing." + .to_string(), + ) + .into()); + } + let mut canonical = read_canonical(&state).await?; + let server = canonical + .iter() + .find(|server| server.id == server_id) + .cloned() + .ok_or_else(|| { + ErrorKind::InputError( + "Only a synced user server can be desynced.".to_string(), + ) + })?; + let local_position = compose_records(&metadata, &state) + .await? + .iter() + .position(|record| record.id == server_id) + .unwrap_or_default() as i64; + let mut locals = load_local(instance_id, &state).await?; + locals.push(LocalServer { + id: Uuid::new_v4().to_string(), + source: ServerSource::LocalDesynced, + excluded_synced_server_id: (mode + == DesyncServerMode::KeepInOtherInstances) + .then(|| server.id.clone()), + data: server.data, + position: local_position, + }); + if mode == DesyncServerMode::RemoveFromOtherInstances { + canonical.retain(|candidate| candidate.id != server_id); + } + let canonical_changed = commit_server_state( + Some(&canonical), + Some((instance_id, &locals)), + &state, + ) + .await?; + if canonical_changed { + regenerate_servers(&state).await?; + } else { + compose_instance(&metadata, &state).await?; + } + Ok(()) +} + +pub async fn list_synced_servers() -> crate::Result> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + Ok(read_canonical(&state) + .await? + .into_iter() + .map(|server| { + let CanonicalServer { id, data } = server; + SyncedServer { + id, + name: data + .get::<_, &str>("name") + .unwrap_or_default() + .to_string(), + address: server_address(&data), + accept_textures: data + .get::<_, i8>("acceptTextures") + .ok() + .map(|value| value != 0), + } + }) + .collect()) +} + +pub async fn update_synced_server(server: SyncedServer) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let mut canonical = read_canonical(&state).await?; + let target = canonical + .iter_mut() + .find(|candidate| candidate.id == server.id) + .ok_or_else(|| { + ErrorKind::InputError("Unknown synced server".to_string()) + })?; + update_server_data( + &mut target.data, + server.name, + server.address, + server.accept_textures, + ); + commit_server_state(Some(&canonical), None, &state).await?; + regenerate_servers(&state).await +} + +pub async fn remove_synced_server(server_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let mut canonical = read_canonical(&state).await?; + let previous_len = canonical.len(); + canonical.retain(|server| server.id != server_id); + if canonical.len() == previous_len { + return Err( + ErrorKind::InputError("Unknown synced server".to_string()).into() + ); + } + commit_server_state(Some(&canonical), None, &state).await?; + regenerate_servers(&state).await +} + +pub(super) async fn compose_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let records = compose_records(metadata, state).await?; + let bytes = servers_to_bytes( + &records + .iter() + .map(|record| record.data.clone()) + .collect::>(), + )?; + let expected = sha1_bytes(&bytes); + let revision = server_revision(state).await?; + begin_server_checkpoint( + &metadata.instance.id, + &records, + &expected, + revision, + state, + ) + .await?; + let generated = generated_path(state, &metadata.instance.id); + if let Some(parent) = generated.parent() { + crate::util::io::create_dir_all(parent).await?; + } + crate::util::io::write(&generated, &bytes).await?; + let target = instance_game_dir(metadata, state)?.join(SERVERS_FILE); + let mode = ensure_link(&generated, &target).await?; + finish_checkpoint( + &metadata.instance.id, + SyncedOption::MultiplayerServers, + "default", + mode, + state, + ) + .await +} + +async fn compose_records( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + let canonical = read_canonical(state).await?; + let locals = load_local(&metadata.instance.id, state).await?; + Ok(merge_server_records(canonical, locals)) +} + +fn merge_server_records( + canonical: Vec, + locals: Vec, +) -> Vec { + let exclusions = locals + .iter() + .filter_map(|server| server.excluded_synced_server_id.as_deref()) + .collect::>(); + let mut records = canonical + .into_iter() + .filter(|server| !exclusions.contains(server.id.as_str())) + .map(|server| ServerRecord { + id: server.id, + source: ServerSource::UserSynced, + data: server.data, + }) + .collect::>(); + let mut locals = locals; + locals.sort_by_key(|server| server.position); + for server in locals { + let position = usize::try_from(server.position) + .unwrap_or(records.len()) + .min(records.len()); + records.insert( + position, + ServerRecord { + id: server.id, + source: server.source, + data: server.data, + }, + ); + } + records +} + +async fn regenerate_servers(state: &State) -> crate::Result<()> { + for metadata in crate::state::list_instances(&state.pool).await? { + if effective(&metadata, state).await? { + compose_instance(&metadata, state).await?; + } + } + Ok(()) +} + +pub(super) async fn effective( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + if sync_files_are_protected(metadata) + || instance_is_running(metadata, state).await? + { + return Ok(false); + } + participating(metadata, state).await +} + +async fn participating( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + if !instance_option_enabled(metadata, SyncedOption::MultiplayerServers) { + return Ok(false); + } + let global_enabled: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM sync_feature_settings + WHERE feature = 'multiplayer_servers' AND globally_enabled = 1 + )", + ) + .fetch_one(&state.pool) + .await?; + + Ok(instance_option_supported( + metadata, + SyncedOption::MultiplayerServers, + global_enabled, + state, + ) + .await) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server(name: &str, address: &str) -> NbtCompound { + super::super::codec::server_data( + name.to_string(), + address.to_string(), + None, + ) + } + + #[test] + fn merge_preserves_local_entries_and_synced_exclusions() { + let records = merge_server_records( + vec![ + CanonicalServer { + id: "synced-a".to_string(), + data: server("Synced A", "a.example.test"), + }, + CanonicalServer { + id: "synced-b".to_string(), + data: server("Synced B", "b.example.test"), + }, + ], + vec![ + LocalServer { + id: "local".to_string(), + source: ServerSource::Modpack, + excluded_synced_server_id: None, + data: server("Pack server", "pack.example.test"), + position: 1, + }, + LocalServer { + id: "desynced-b".to_string(), + source: ServerSource::LocalDesynced, + excluded_synced_server_id: Some("synced-b".to_string()), + data: server("Private B", "b.example.test"), + position: 2, + }, + ], + ); + + assert_eq!( + records + .iter() + .map(|record| (record.id.as_str(), record.source)) + .collect::>(), + vec![ + ("synced-a", ServerSource::UserSynced), + ("local", ServerSource::Modpack), + ("desynced-b", ServerSource::LocalDesynced), + ] + ); + } + + #[test] + fn projection_matching_prefers_exact_then_address_then_name() { + let projections = vec![ + ProjectionEntry { + id: "exact".to_string(), + owner: ProjectionOwner::Synced, + data: server("Exact", "exact.example.test"), + position: 2, + }, + ProjectionEntry { + id: "address".to_string(), + owner: ProjectionOwner::Synced, + data: server("Old name", "same.example.test"), + position: 0, + }, + ProjectionEntry { + id: "name".to_string(), + owner: ProjectionOwner::Instance, + data: server("Same name", "old.example.test"), + position: 1, + }, + ]; + let current = vec![ + server("Exact", "exact.example.test"), + server("New name", "same.example.test"), + server("Same name", "new.example.test"), + ]; + + let matches = match_projection_entries(¤t, &projections); + assert_eq!( + matches + .into_iter() + .map(|projection| projection + .map(|projection| projection.id.as_str())) + .collect::>(), + vec![Some("exact"), Some("address"), Some("name")] + ); + } +} diff --git a/packages/app-lib/src/api/instance/synced_servers/storage.rs b/packages/app-lib/src/api/instance/synced_servers/storage.rs new file mode 100644 index 0000000..deeafd9 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/storage.rs @@ -0,0 +1,371 @@ +use super::super::synced_options::{ + nbt_from_bytes, nbt_to_bytes, safe_instance_id, synced_options_path, +}; +use super::SERVERS_FILE; +use super::types::{ + CanonicalServer, LocalServer, ProjectionEntry, ProjectionOwner, + ServerRecord, ServerSource, +}; +use crate::{ErrorKind, State}; +use sqlx::{Executor, Sqlite, Transaction}; +use std::collections::HashSet; +use std::path::PathBuf; + +pub(in crate::api::instance) async fn canonical_exists( + state: &State, +) -> crate::Result { + canonical_initialized(state).await +} + +async fn canonical_initialized(state: &State) -> crate::Result { + Ok(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM synced_server_state WHERE singleton = 1 + )", + ) + .fetch_one(&state.pool) + .await?) +} + +pub(super) async fn read_canonical( + state: &State, +) -> crate::Result> { + read_canonical_with(&state.pool).await +} + +async fn read_canonical_with<'e>( + executor: impl Executor<'e, Database = Sqlite>, +) -> crate::Result> { + let rows = sqlx::query( + "SELECT id, nbt + FROM synced_servers + ORDER BY position", + ) + .fetch_all(executor) + .await?; + use sqlx::Row; + rows.into_iter() + .map(|row| { + let id: String = row.get("id"); + let nbt: Vec = row.get("nbt"); + Ok(CanonicalServer { + id, + data: nbt_from_bytes(nbt)?, + }) + }) + .collect() +} + +/// Reads canonical servers and instance overrides from the same committed revision. +pub(super) async fn read_server_snapshot( + instance_id: &str, + state: &State, +) -> crate::Result<(Vec, Vec)> { + let mut tx = state.pool.begin().await?; + let canonical = read_canonical_with(&mut *tx).await?; + let local = load_local_with(instance_id, &mut *tx).await?; + tx.commit().await?; + Ok((canonical, local)) +} + +pub(super) async fn commit_server_state( + canonical: Option<&[CanonicalServer]>, + local: Option<(&str, &[LocalServer])>, + state: &State, +) -> crate::Result { + let mut tx = state.pool.begin().await?; + let mut canonical_changed = false; + if let Some(canonical) = canonical { + canonical_changed = write_canonical_rows(&mut tx, canonical).await?; + sqlx::query( + "INSERT INTO synced_server_state (singleton, revision) + VALUES (1, 0) + ON CONFLICT(singleton) DO NOTHING", + ) + .execute(&mut *tx) + .await?; + if canonical_changed { + sqlx::query( + "UPDATE synced_server_state + SET revision = revision + 1 + WHERE singleton = 1", + ) + .execute(&mut *tx) + .await?; + } + } + if let Some((instance_id, local)) = local { + write_local_rows(&mut tx, instance_id, local).await?; + } + tx.commit().await?; + Ok(canonical_changed) +} + +async fn write_canonical_rows( + tx: &mut Transaction<'_, Sqlite>, + servers: &[CanonicalServer], +) -> crate::Result { + let current = sqlx::query( + "SELECT id, position, nbt + FROM synced_servers + ORDER BY position", + ) + .fetch_all(&mut **tx) + .await?; + let mut desired = Vec::with_capacity(servers.len()); + for (position, server) in servers.iter().enumerate() { + desired.push(( + server.id.as_str(), + position as i64, + nbt_to_bytes(&server.data)?, + )); + } + use sqlx::Row; + if current.len() == desired.len() + && current.iter().zip(&desired).all(|(current, desired)| { + let id: String = current.get("id"); + let position: i64 = current.get("position"); + let nbt: Vec = current.get("nbt"); + id == desired.0 && position == desired.1 && nbt == desired.2 + }) + { + return Ok(false); + } + + let desired_ids = + desired.iter().map(|(id, _, _)| *id).collect::>(); + for row in ¤t { + let row_id: String = row.get("id"); + if !desired_ids.contains(row_id.as_str()) { + sqlx::query("DELETE FROM synced_servers WHERE id = ?") + .bind(&row_id) + .execute(&mut **tx) + .await?; + } + } + + let position_offset = current + .iter() + .map(|row| { + let p: i64 = row.get("position"); + p + }) + .max() + .unwrap_or(0) + .max(servers.len() as i64) + .saturating_add(1); + sqlx::query("UPDATE synced_servers SET position = position + ?") + .bind(position_offset) + .execute(&mut **tx) + .await?; + + for (id, position, nbt) in desired { + sqlx::query( + "INSERT INTO synced_servers (id, position, nbt) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + position = excluded.position, + nbt = excluded.nbt", + ) + .bind(id) + .bind(position) + .bind(nbt) + .execute(&mut **tx) + .await?; + } + Ok(true) +} + +pub(super) async fn server_revision(state: &State) -> crate::Result { + Ok(sqlx::query_scalar::<_, i64>( + "SELECT revision + FROM synced_server_state + WHERE singleton = 1", + ) + .fetch_optional(&state.pool) + .await? + .unwrap_or(0)) +} + +pub(super) async fn load_local( + instance_id: &str, + state: &State, +) -> crate::Result> { + load_local_with(instance_id, &state.pool).await +} + +async fn load_local_with<'e>( + instance_id: &str, + executor: impl Executor<'e, Database = Sqlite>, +) -> crate::Result> { + let rows = sqlx::query( + "SELECT id, source, excluded_synced_server_id, nbt, position + FROM instance_servers + WHERE instance_id = ? + ORDER BY position", + ) + .bind(instance_id) + .fetch_all(executor) + .await?; + use sqlx::Row; + rows.into_iter() + .map(|row| { + let id: String = row.get("id"); + let source: String = row.get("source"); + let excluded_synced_server_id: Option = + row.get("excluded_synced_server_id"); + let nbt: Vec = row.get("nbt"); + let position: i64 = row.get("position"); + Ok(LocalServer { + id, + source: ServerSource::from_str(&source).ok_or_else(|| { + ErrorKind::InputError(format!( + "Unknown server source {source}" + )) + })?, + excluded_synced_server_id, + data: nbt_from_bytes(nbt)?, + position, + }) + }) + .collect() +} + +pub(super) async fn write_local( + instance_id: &str, + servers: &[LocalServer], + state: &State, +) -> crate::Result<()> { + commit_server_state(None, Some((instance_id, servers)), state).await?; + Ok(()) +} + +pub(super) async fn write_local_rows( + tx: &mut Transaction<'_, Sqlite>, + instance_id: &str, + servers: &[LocalServer], +) -> crate::Result<()> { + sqlx::query("DELETE FROM instance_servers WHERE instance_id = ?") + .bind(instance_id) + .execute(&mut **tx) + .await?; + for server in servers { + let source = server.source.as_str(); + let nbt = nbt_to_bytes(&server.data)?; + sqlx::query( + "INSERT INTO instance_servers + (instance_id, id, source, excluded_synced_server_id, + nbt, position) + VALUES (?, ?, ?, + (SELECT id FROM synced_servers WHERE id = ?), ?, ?)", + ) + .bind(instance_id) + .bind(&server.id) + .bind(source) + .bind(&server.excluded_synced_server_id) + .bind(nbt) + .bind(server.position) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +pub(super) async fn load_projection_entries( + instance_id: &str, + state: &State, +) -> crate::Result> { + let rows = sqlx::query( + "SELECT server_id, owner, nbt, position + FROM instance_server_projection_entries + WHERE instance_id = ? + ORDER BY position", + ) + .bind(instance_id) + .fetch_all(&state.pool) + .await?; + use sqlx::Row; + rows.into_iter() + .map(|row| { + let server_id: String = row.get("server_id"); + let owner: String = row.get("owner"); + let nbt: Vec = row.get("nbt"); + let position: i64 = row.get("position"); + Ok(ProjectionEntry { + id: server_id, + owner: ProjectionOwner::from_str(&owner).ok_or_else(|| { + ErrorKind::InputError(format!( + "Unknown server projection owner {owner}" + )) + })?, + data: nbt_from_bytes(nbt)?, + position, + }) + }) + .collect() +} + +pub(super) async fn begin_server_checkpoint( + instance_id: &str, + servers: &[ServerRecord], + expected_sha1: &str, + source_revision: i64, + state: &State, +) -> crate::Result<()> { + let mut tx = state.pool.begin().await?; + sqlx::query( + "DELETE FROM instance_server_projection_entries WHERE instance_id = ?", + ) + .bind(instance_id) + .execute(&mut *tx) + .await?; + for (position, server) in servers.iter().enumerate() { + let owner = if server.source == ServerSource::UserSynced { + ProjectionOwner::Synced + } else { + ProjectionOwner::Instance + } + .as_str(); + let nbt = nbt_to_bytes(&server.data)?; + let position = position as i64; + sqlx::query( + "INSERT INTO instance_server_projection_entries + (instance_id, owner, server_id, nbt, position) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(instance_id) + .bind(owner) + .bind(&server.id) + .bind(nbt) + .bind(position) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "INSERT INTO instance_sync_checkpoints + (instance_id, feature, variant, expected_sha1, merge_base, + source_revision, status, link_mode) + VALUES (?, 'multiplayer_servers', 'default', ?, NULL, ?, + 'pending', NULL) + ON CONFLICT(instance_id, feature, variant) DO UPDATE SET + expected_sha1 = excluded.expected_sha1, + merge_base = NULL, + source_revision = excluded.source_revision, + status = 'pending', + link_mode = NULL", + ) + .bind(instance_id) + .bind(expected_sha1) + .bind(source_revision) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +pub(super) fn generated_path(state: &State, instance_id: &str) -> PathBuf { + synced_options_path(state) + .join("servers/generated") + .join(safe_instance_id(instance_id)) + .join(SERVERS_FILE) +} diff --git a/packages/app-lib/src/api/instance/synced_servers/types.rs b/packages/app-lib/src/api/instance/synced_servers/types.rs new file mode 100644 index 0000000..97b78d6 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_servers/types.rs @@ -0,0 +1,131 @@ +use quartz_nbt::NbtCompound; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerSource { + UserSynced, + Modpack, + LinkedServerProject, + LocalDesynced, +} + +impl ServerSource { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::UserSynced => "user_synced", + Self::Modpack => "modpack", + Self::LinkedServerProject => "linked_server_project", + Self::LocalDesynced => "local_desynced", + } + } + + pub(super) fn from_str(value: &str) -> Option { + match value { + "user_synced" => Some(Self::UserSynced), + "modpack" => Some(Self::Modpack), + "linked_server_project" => Some(Self::LinkedServerProject), + "local_desynced" => Some(Self::LocalDesynced), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DesyncServerMode { + KeepInOtherInstances, + RemoveFromOtherInstances, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SyncedServer { + pub id: String, + pub name: String, + pub address: String, + pub accept_textures: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct ServerRecord { + pub id: String, + pub source: ServerSource, + pub data: NbtCompound, +} + +impl ServerRecord { + pub fn name(&self) -> String { + self.data + .get::<_, &str>("name") + .unwrap_or_default() + .to_string() + } + + pub fn address(&self) -> String { + self.data + .get::<_, &str>("ip") + .unwrap_or_default() + .to_string() + } + + pub fn icon(&self) -> Option { + self.data.get::<_, &str>("icon").ok().map(ToOwned::to_owned) + } + + pub fn hidden(&self) -> bool { + self.data.get::<_, i8>("hidden").unwrap_or(0) != 0 + } + + pub fn accept_textures(&self) -> Option { + self.data + .get::<_, i8>("acceptTextures") + .ok() + .map(|value| value != 0) + } +} + +#[derive(Clone)] +pub(super) struct CanonicalServer { + pub id: String, + pub data: NbtCompound, +} + +#[derive(Clone)] +pub(super) struct LocalServer { + pub id: String, + pub source: ServerSource, + pub excluded_synced_server_id: Option, + pub data: NbtCompound, + pub position: i64, +} + +#[derive(Clone)] +pub(super) struct ProjectionEntry { + pub id: String, + pub owner: ProjectionOwner, + pub data: NbtCompound, + pub position: i64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ProjectionOwner { + Synced, + Instance, +} + +impl ProjectionOwner { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Synced => "synced", + Self::Instance => "instance", + } + } + + pub(super) fn from_str(value: &str) -> Option { + match value { + "synced" => Some(Self::Synced), + "instance" => Some(Self::Instance), + _ => None, + } + } +} 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/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index 5e9c524..597f98a 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -47,6 +47,20 @@ use tokio::io::AsyncReadExt; use tokio::process::Command; use tokio_util::sync::CancellationToken; +pub(crate) struct GameVersionMetadata { + pub world_version: Option, +} + +/// Reads the embedded `version.json` DataVersion from a client jar. +/// +/// Ported as a stub to match the upstream Axolotl implementation, which also +/// returns `None`; callers fall back to the legacy data-version table. +pub(crate) async fn read_game_version_metadata_from_jar( + _path: &Path, +) -> crate::Result> { + Ok(None) +} + #[cfg(target_os = "windows")] use winreg::{RegKey, enums::HKEY_CURRENT_USER}; diff --git a/packages/app-lib/src/state/dirs.rs b/packages/app-lib/src/state/dirs.rs index eae7211..4d48b76 100644 --- a/packages/app-lib/src/state/dirs.rs +++ b/packages/app-lib/src/state/dirs.rs @@ -68,10 +68,17 @@ impl DirectoryInfo { /// Get the Minecraft instance metadata directory #[inline] + pub fn metadata_dir(&self) -> PathBuf { self.config_dir.join(METADATA_FOLDER_NAME) } + /// Get the directory containing launcher-wide synced option files. + #[inline] + pub(crate) fn synced_options_dir(&self) -> PathBuf { + self.config_dir.join("synced-options") + } + /// Get the Minecraft java versions metadata directory #[inline] pub fn java_versions_dir(&self) -> PathBuf { diff --git a/packages/app-lib/src/state/game_options.rs b/packages/app-lib/src/state/game_options.rs new file mode 100644 index 0000000..6a06e6b --- /dev/null +++ b/packages/app-lib/src/state/game_options.rs @@ -0,0 +1,68 @@ +use super::{CanonicalValue, GameOptionKind, StoredOption, StoredPreference}; +use sqlx::{Row, SqlitePool}; +use std::collections::HashMap; + +pub(crate) async fn shared_game_options_exist( + pool: &SqlitePool, +) -> crate::Result { + Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM synced_game_option_state WHERE singleton = 1)").fetch_one(pool).await?) +} + +pub(crate) async fn game_options_sync_is_enabled( + pool: &SqlitePool, +) -> crate::Result { + Ok(sqlx::query_scalar("SELECT globally_enabled FROM sync_feature_settings WHERE feature = 'game_options'").fetch_optional(pool).await?.unwrap_or(false)) +} + +pub(crate) async fn load_game_options_sync_state( + pool: &SqlitePool, + current_catalog_revision: u32, +) -> crate::Result<(u64, u32)> { + let row = sqlx::query("SELECT revision, catalog_revision FROM synced_game_option_state WHERE singleton = 1").fetch_optional(pool).await?; + Ok(row + .map(|r| { + ( + r.get::("revision").max(0) as u64, + (r.get::("catalog_revision").max(1) as u32) + .max(current_catalog_revision), + ) + }) + .unwrap_or((0, current_catalog_revision))) +} + +pub(crate) async fn load_shared_game_options( + pool: &SqlitePool, +) -> crate::Result> { + let rows = sqlx::query("SELECT option_id, kind, raw_key, canonical_value_json, seeded, revision FROM synced_game_option_values").fetch_all(pool).await?; + let mut out = HashMap::with_capacity(rows.len()); + for row in rows { + let option_id: String = row.get("option_id"); + let kind: String = row.get("kind"); + let raw_key: Option = row.try_get("raw_key")?; + let json: Option = row.try_get("canonical_value_json")?; + let seeded: bool = row.get("seeded"); + let revision: i64 = row.get("revision"); + out.insert( + option_id.clone(), + StoredOption { + option_id, + kind: if kind == "external" { + GameOptionKind::External + } else { + GameOptionKind::Vanilla + }, + raw_key, + value: json.as_deref().map(serde_json::from_str).transpose()?, + seeded, + revision: revision.max(0) as u64, + }, + ); + } + Ok(out) +} + +pub(crate) async fn load_game_option_preferences( + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query("SELECT option_id, enabled, revision FROM synced_game_option_preferences").fetch_all(pool).await?.into_iter().map(|r| { let option_id: String = r.get("option_id"); let enabled: bool = r.get("enabled"); let revision: i64 = r.get("revision"); (option_id, StoredPreference { enabled, revision: revision.max(0) as u64 }) }).collect()) +} 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..06c5c0b 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,50 @@ mod tests { assert_eq!(launch.applied_content_set.revision, 5); } } + +pub(crate) async fn set_instance_sync_preference( + instance_id: &str, + option: crate::state::SyncedOption, + enabled: bool, + pool: &SqlitePool, +) -> crate::Result<()> { + sqlx::query( + "INSERT INTO instance_sync_preferences (instance_id, feature, enabled) + VALUES (?, ?, ?) + ON CONFLICT(instance_id, feature) DO UPDATE SET enabled = excluded.enabled", + ) + .bind(instance_id) + .bind(option.as_str()) + .bind(enabled) + .execute(pool) + .await?; + Ok(()) +} + +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/game_options.rs b/packages/app-lib/src/state/instances/model/game_options.rs new file mode 100644 index 0000000..f5865ab --- /dev/null +++ b/packages/app-lib/src/state/instances/model/game_options.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum CanonicalValue { + Bool(bool), + Integer(i64), + Decimal(String), + Enum(String), + Text(String), + StringList(Vec), + KeyBinding(String), + ExternalRaw(String), +} + +impl CanonicalValue { + pub(crate) fn type_name(&self) -> &'static str { + match self { + Self::Bool(_) => "bool", + Self::Integer(_) => "integer", + Self::Decimal(_) => "decimal", + Self::Enum(_) => "enum", + Self::Text(_) => "text", + Self::StringList(_) => "string_list", + Self::KeyBinding(_) => "key_binding", + Self::ExternalRaw(_) => "external_raw", + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GameOptionKind { + Vanilla, + External, +} + +#[derive(Clone)] +pub(crate) struct StoredOption { + pub(crate) option_id: String, + pub(crate) kind: GameOptionKind, + pub(crate) raw_key: Option, + pub(crate) value: Option, + pub(crate) seeded: bool, + pub(crate) revision: u64, +} + +#[derive(Clone)] +pub(crate) struct StoredPreference { + pub(crate) enabled: bool, + pub(crate) revision: u64, +} diff --git a/packages/app-lib/src/state/instances/model/instance.rs b/packages/app-lib/src/state/instances/model/instance.rs index 1c448dd..8688ab5 100644 --- a/packages/app-lib/src/state/instances/model/instance.rs +++ b/packages/app-lib/src/state/instances/model/instance.rs @@ -88,3 +88,67 @@ 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, +} + +/// The set of per-instance synchronization features. Mirrors the Axolotl +/// `SyncedOption` enum, including its string keys used by the +/// `instance_sync_preferences` / `sync_feature_settings` tables. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncedOption { + GameOptions, + CommandHistory, + MultiplayerServers, + CreativeHotbars, + Screenshots, + ResourcePacks, + DataPacks, +} + +impl SyncedOption { + pub const ALL: [Self; 7] = [ + Self::GameOptions, + Self::CommandHistory, + Self::MultiplayerServers, + Self::CreativeHotbars, + Self::Screenshots, + Self::ResourcePacks, + Self::DataPacks, + ]; + + pub const fn is_available(self) -> bool { + true + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::GameOptions => "game_options", + Self::CommandHistory => "command_history", + Self::MultiplayerServers => "multiplayer_servers", + Self::CreativeHotbars => "creative_hotbars", + Self::Screenshots => "screenshots", + Self::ResourcePacks => "resource_packs", + Self::DataPacks => "data_packs", + } + } +} diff --git a/packages/app-lib/src/state/instances/model/mod.rs b/packages/app-lib/src/state/instances/model/mod.rs index 288449e..5a623bc 100644 --- a/packages/app-lib/src/state/instances/model/mod.rs +++ b/packages/app-lib/src/state/instances/model/mod.rs @@ -65,3 +65,5 @@ pub use self::update_check::*; fn unknown_value(kind: &str, value: &str) -> crate::Error { crate::ErrorKind::InputError(format!("Unknown {kind} {value}")).into() } +mod game_options; +pub use self::game_options::*; diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 3875ae1..dde048c 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -67,6 +67,9 @@ pub use self::mr_auth::*; mod legacy_converter; +mod game_options; +pub use self::game_options::*; + pub mod attached_world_data; pub mod server_join_log; @@ -148,6 +151,9 @@ pub struct State { configured_http_client_update: AsyncMutex<()>, pub(crate) file_watcher: FileWatcher, + + pub(crate) synced_options_lock: Arc>, + pub(crate) game_locale_indexer: crate::api::instance::synced_options::game_options::locales::GameLocaleIndexer, } #[derive(Default)] @@ -555,6 +561,12 @@ impl State { )) } + pub(crate) async fn lock_synced_options( + &self, + ) -> tokio::sync::OwnedMutexGuard<()> { + self.synced_options_lock.clone().lock_owned().await + } + pub fn initialized() -> bool { LAUNCHER_STATE.initialized() } @@ -936,6 +948,8 @@ impl State { configured_http_client: RwLock::new(configured_http_client), configured_http_client_update: AsyncMutex::new(()), file_watcher, + synced_options_lock: Arc::new(AsyncMutex::new(())), + game_locale_indexer: Default::default(), // app_identifier, })) } @@ -969,6 +983,13 @@ impl State { } } +pub(crate) async fn instance_has_running_process( + instance_id: &str, + state: &State, +) -> crate::Result { + Ok(state.process_manager.has_instance_process(instance_id)) +} + #[cfg(test)] pub(crate) async fn test_state( directories: DirectoryInfo, @@ -1016,6 +1037,8 @@ pub(crate) async fn test_state( configured_http_client: RwLock::new(configured_http_client), configured_http_client_update: AsyncMutex::new(()), file_watcher, + synced_options_lock: Arc::new(AsyncMutex::new(())), + game_locale_indexer: Default::default(), })) } diff --git a/packages/ui/src/components/base/buttons/CheckCircleButton.vue b/packages/ui/src/components/base/buttons/CheckCircleButton.vue new file mode 100644 index 0000000..53e1d20 --- /dev/null +++ b/packages/ui/src/components/base/buttons/CheckCircleButton.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index 877f82a..250489c 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -11,6 +11,7 @@ export { default as BulletDivider } from './BulletDivider.vue' export { default as Button } from './Button.vue' export { default as NewButton } from './buttons/Button.vue' export { default as IconButton } from './buttons/IconButton.vue' +export { default as CheckCircleButton } from './buttons/CheckCircleButton.vue' export type { ButtonColor, ButtonElementHandle, @@ -78,6 +79,7 @@ export { default as NavTabs } from './NavTabs.vue' export { default as OptionGroup } from './OptionGroup.vue' export type { Option as OverflowMenuOption } from './OverflowMenu.vue' export { default as OverflowMenu } from './OverflowMenu.vue' +export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue' export { default as Page } from './Page.vue' export { default as Pagination } from './Pagination.vue' export { default as PopoutMenu } from './PopoutMenu.vue'