Compare commits

..
Author SHA1 Message Date
AxTps 7f3ab9d79b fix: 补 EditInstance DTO 缺失的 window_title 字段与透传
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:23:49 +08:00
AxTps 037bff2db3 Merge branch 'main' into feat/launch-customization
Sync source to CNB / Sync Git ref (push) Skipped
解决 en-US/zh-CN locale JSON 的末尾追加冲突:窗口标题键与公告中心键均保留。
2026-10-10 22:07:19 +08:00
AxTps 1406f7ded3 fix: 补 ProcessMetadata 测试构造点缺失的 window_title
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:01:42 +08:00
Ap_Tx c54acd2e36 fix: 补齐盔甲预览的中文翻译 (#11)
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
皮肤编辑器盔甲面板的 46 条文案此前只有英文,补上 packages/ui 的中英对照。
2026-10-10 21:54:50 +08:00
AxTps 1b6534ad8c fix: changelog 评论 workflow 补 pull-requests write 权限
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
2026-10-10 21:53:05 +08:00
AxTps 2477fc62ca fix: 补窗口标题字段缺失的构造点
Sync source to CNB / Sync Git ref (push) Skipped
修 E0063:InstanceLaunchOverridesPatch 和 InstanceLaunchOverrides 的两处完整构造漏了 window_title。
2026-10-10 21:48:20 +08:00
Ap_Tx 3f6678ac2f feat: 移植远程公告中心 (#9)
Sync source to CNB / Sync Git ref (push) Skipped
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
从 Axolotl 搬 RemoteAnnouncements.vue,顶栏加公告铃铛。远程拉取先禁用,等接口。
2026-10-10 21:32:11 +08:00
AxTps e36329ff0e style: 窗口标题改动的 cargo fmt 格式修正
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 21:30:15 +08:00
AxTps c718d75774 feat: 支持自定义 Minecraft 窗口标题
Sync source to CNB / Sync Git ref (push) Skipped
全局开关 + 默认标题,实例可覆盖;Win32 重命名窗口。前端设置页同步。
2026-10-10 21:02:34 +08:00
AxTps 544611738d docs: 1.0.2-beta4 公告并入盔甲预览
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
beta4 此前未打 tag,本次发布将其与皮肤编辑器盔甲预览
移植(PR #8)合并发布,故在 beta4 公告条目补充:
- 皮肤预览新增盔甲与盔甲纹饰渲染
- 盔甲预览选择的本地持久化
2026-10-10 19:25:39 +08:00
AxTps 403a883e0b fix: CI 钉住 cargo-shear 版本为 1.13.4
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
check-rust.yml 的 shear 任务在 Rust 1.95 工具链下装最新
cargo-shear(1.14.0) 失败:其依赖 ra_ap_syntax 0.0.352 要求
rustc >= 1.98。且 binstall 走 GitHub release 下载时遭遇
api.github.com 403,回退源码编译后同样因版本过高而失败。

钉到 cargo-shear@1.13.4(依赖 ra_ap_syntax 0.0.347,要求
rustc 1.95),与 CI 的 stable 工具链兼容。
2026-10-09 22:20:48 +08:00
AxTps 53c749aaea fix: 补齐 skin-rendering barrel 的盔甲导出
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
PR #8 合并后 CI 打包报 MISSING_EXPORT:ArmorTrimPanel.vue 与
SkinPreviewRenderer.vue 从 #ui/composables/skin-rendering 导入
ARMOR_SLOTS / ARMOR_TRIM_MATERIALS / ARMOR_TRIM_PATTERNS /
armorMaterialsForSlot / createDefaultArmorPreviewConfig /
useArmorPreview,但该 barrel 未导出这些符号。

补上 armor-preview-types 与 use-armor-preview 的导出。
本地 pnpm --filter @modrinth/app-frontend build 已通过。
2026-10-09 22:12:41 +08:00
Ap_Tx addc31c7c1 Merge pull request #8 from ApTx2012/feat/axolotl-port
feat: 移植 Axolotl 皮肤盔甲预览(Armor Preview)
2026-10-09 21:50:24 +08:00
AxTps 25978bcd8f Merge remote-tracking branch 'origin2/main'
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
2026-10-09 21:46:12 +08:00
AxTps 117eaad9ff style: 对分组修复涉及的 Rust 文件补 cargo fmt 2026-10-09 21:44:22 +08:00
Disy 91e2c369f5 fix: 优化下载并发调度,发布 1.0.2-beta4
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
2026-10-08 12:10:54 +08:00
AxTps ae2992d7f8 feat: 移植 Axolotl 皮肤盔甲预览(Armor Preview)
Sync source to CNB / Sync Git ref (push) Canceled after 0s
从 Axolotl 1.9.7 移植皮肤编辑器的盔甲/盔甲纹饰预览功能,
让玩家在皮肤选择页可试穿盔甲并调整纹饰。

新增:
- 前端组件 ArmorTrimTab / SkinListSkeleton 及 skin-armor-preview helper
- packages/ui 组件 ArmorPreviewControls / ArmorTrimPanel
- packages/ui armor 预览 composable(armor-preview-assets/types、use-armor-preview)
- packages/ui webgl 盔甲渲染(armor-geometry / armor-preview-object / armor-rendering)
- packages/ui/src/assets/minecraft-armor 盔甲素材

改动:
- SkinPreviewRenderer 增加 armorPreview/armorControls props、
  armorConfig model 与 useArmorPreview 集成
- Skins.vue 增加盔甲 tab、状态逻辑与模板接入
- 补齐 skin/composables/utils 的导出

适配说明:ArmorPreviewControls 原版依赖 reka-ui 的 popover,
Starlight 无该依赖且只用 external 模式,故改写为 external-only,
去除 reka-ui 与 overlays.css 依赖。
2026-10-07 20:01:19 +08:00
AxTps 20d5d2baab fix: 修复实例分组无法保存的问题
direct-link(外部 .minecraft 关联)同步逻辑会把来源路径当作分组名
写入 instance_groups 表,每次同步都覆盖用户手动设置的分组,导致
分组当场生效、重启丢失。

移除三处 direct-link 相关的来源分组写入,使 instance_groups 表
专属于用户分组:
- sync_direct_link_instances: 同步时不再覆盖 groups
- create_direct_link_instance: 建实例时不再写来源分组
- launcher::promote_external_instance_link: 提升外部实例时不再写分组
2026-10-06 09:50:01 +08:00
AxTps 748d24b264 fix: 修复周年庆横幅被标题栏遮挡与背景遮字问题,发布 1.0.2-beta3
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-10-04 15:06:20 +08:00
AxTps bb0b094002 fix: migration guard 豁免删除分支
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-04 13:39:09 +08:00
AxTps 386db4bdb5 fix: 豁免被删除的 000050 迁移文件
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-04 13:36:53 +08:00
AxTps f98dbf1592 fix: migration guard 豁免补建表引发的删除与分歧
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-04 13:26:59 +08:00
AxTps 48aff7550a fix: 补建 synced_pack_catalog / synced_pack_instances,发布 1.0.2-beta2
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
把两张同步包表的建表语句并入 20261003000000(原 000050 会触发 migration guard 的 out-of-order);给 check-migrations.mjs 增加 allowedHistoricalFixes 豁免;更新 catalog 到 1.0.2-beta2。
2026-10-04 13:16:34 +08:00
AxTps c000e6a535 fix: 补建跨实例同步缺失的 synced_pack_catalog / synced_pack_instances 表
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
移植跨实例同步时遗漏了这两张表,导致 20261003000200_synced-pack-selection.sql 对不存在的 synced_pack_catalog 执行 ALTER 而迁移失败、启动器无法初始化。新增 20261003000050_synced-pack-tables.sql(顺序位于 feature-tables 之后、pack-selection 之前)补建。
2026-10-04 12:46:12 +08:00
AxTps 2ed2791316 docs: 1.0.2-beta1 更新日志补充跨实例同步
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-04 11:27:16 +08:00
AxTps d422f3aeaf docs: 补充 1.0.2-beta1 更新日志(周年庆彩蛋)
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-04 11:26:45 +08:00
AxTps a21fe3750b feat: 新增 Starlight 周年庆彩蛋(每年 10 月 4 日)
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
10 月 4 日(Starlight 首个发布日 2014-10-04)当天,启动器顶部显示周年横幅并飘落蛋糕/彩带,周年数按年份-2014 计算。新增 helpers/anniversary.ts 与 AnniversaryCelebration.vue;开发者页加入 force_anniversary_celebration 功能开关用于随时预览。
2026-10-04 11:23:27 +08:00
Ap_Tx 8a447f33f6 Merge pull request #7 from ApTx2012/feat/instance-sync
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Axolotl→Starlight 的跨实例同步移植,含后端命令层 + 前端 UI。
2026-10-04 10:59:58 +08:00
AxTps f73bc89f84 fix: 修复 CI 的 rustfmt 与 typos 报错
Sync source to CNB / Sync Git ref (push) Has been cancelled
为 instance 同步命令定义之间补空行并补齐文件末尾换行;将 game-settings-modal 的 languages.ts(Minecraft 语言名数据)加入 typos 排除,避免语言代码被误判为拼写错误。
2026-10-04 09:23:04 +08:00
AxTps 4f220201b3 feat: 跨实例同步移植第9轮(游戏设置编辑器)
Sync source to CNB / Sync Git ref (push) Has been cancelled
搬入 game-settings-modal(游戏选项精细编辑,10 文件):设置行编辑器、按键绑定、语言选择与文案表。新增 app-settings-modal provider 与 instance-settings-context 转发,并把 instance-settings 的 closeModal 改为支持关闭回调。SyncSettings 恢复游戏设置入口。补齐 game-options 相关 en-US / zh-CN 文案。
2026-10-04 09:18:33 +08:00
AxTps 83ee962c16 feat: 跨实例同步移植第8轮(前端 helpers + 同步设置 UI)
Sync source to CNB / Sync Git ref (push) Has been cancelled
新增同步 helpers(synced-options / synced-packs / game-options),在 instance helper 中补充同步类型与 API。新增实例设置「同步」标签页(SyncSettings)及 SyncSource / SyncedPacks / servers / command-history 四个弹窗,并把 CheckCircleButton、TeleportOverflowMenu 从 ui 包导出。同步相关的 en-US / zh-CN 文案已补齐(各 62 条)。game-settings-modal 留待下一轮。
2026-10-04 08:51:45 +08:00
AxTps f7380f1590 feat: 跨实例同步移植第7轮(命令注册 + ACL)
Sync source to CNB / Sync Git ref (push) Has been cancelled
在 apps/app 的 instance 插件注册 27 个同步命令(synced_options / synced_servers / synced_packs / game_options),并在 build.rs 补齐对应 ACL 权限。同时在 app-lib 的 api/instance 模块导出 InstanceSyncedOptions 与 SyncedOption,修复命令层 theseus::instance::InstanceSyncedOptions 的解析路径。
2026-10-04 08:37:39 +08:00
AxTps 121da431ed feat: 跨实例同步移植 local_settings_editor(2c 收尾)
Sync source to CNB / Sync Git ref (push) Has been cancelled
- local_settings_editor.rs:单实例(未同步)游戏设置的读取/预览/保存

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

- api/instance/mod.rs 补 game_options 的公开导出(get_local_game_options_config 等)
2026-10-04 08:28:03 +08:00
AxTps b7c16594ce fix: 修复跨实例同步移植的首轮编译错误
Sync source to CNB / Sync Git ref (push) Has been cancelled
- re-export 可见性:synced_options_path 改从 files 导入;SyncedOptionJoinResolution 改从 pending 导入

- bind 移动语义:18 处 .bind(x) 改为 .bind(&x)(metadata.instance.id / option_id / custom_id 等)

- storage.rs:SqliteRow 字段访问改为 row.get()

- modpack.rs:去掉 Axolotl 独有的 DownloadMeta/DownloadReason,改用本地 5 参数 fetch

- state/mod.rs:补齐第 2 处 State 初始化的新增字段
2026-10-04 08:13:40 +08:00
AxTps fae16a0184 style: 对跨实例同步移植代码执行 cargo fmt
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-04 07:42:48 +08:00
AxTps 6e9485c2f5 feat: 跨实例同步移植第5轮(orchestration 及循环依赖解耦)
Sync source to CNB / Sync Git ref (push) Has been cancelled
- orchestration.rs:同步子系统编排核心(能力判定/全局与实例开关/重协调)

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

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

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

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

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

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

已知:未编译验证;local_settings_editor 尚未搬入。
2026-10-04 07:38:10 +08:00
AxTps ed61a5942c feat: 跨实例同步移植 WIP 检查点(第1轮+2a+2b+synced_servers)
Sync source to CNB / Sync Git ref (push) Has been cancelled
从 Axolotl 移植跨实例同步子系统,当前进度:

- 第1轮:synced_options/files.rs + mod.rs 骨架,state 层补齐 SyncedOption/InstanceSyncedOptions/CanonicalValue/GameOptionKind 等

- 2a:game_options 的 api_types/options_file/catalog(10文件)

- 2b:game_options 除 resource_pack_options/local_settings_editor 外的全部(settings_editor/write_shared_settings/fullscreen/launch_overrides/instance_support/read_instance_changes/pack_updates/source_selection + locales/5文件);State 补 synced_options_lock/game_locale_indexer/lock_synced_options

- synced_servers:6 文件全部搬入

- 35+20 处 sqlx::query! 宏改写为非宏 query+bind

已知未完成:synced_servers::operations 依赖 synced_options::instance_option_supported(属 orchestration,第5轮),当前编译不通。
2026-10-03 22:18:37 +08:00
AxTps 5a289c3a15 feat(sync): 跨实例同步第1轮——同步功能表、InstanceSyncedOptions 类型与实例元数据字段
Sync source to CNB / Sync Git ref (push) Has been cancelled
- 新增 4 个 migration:同步功能表/instance_servers 扩展/synced_pack_selection/禁用发现选项

- 新增 InstanceSyncedOptions 类型(per-instance 同步开关)

- InstanceMetadata 增加 synced_options 字段并在 3 个查询点填充

- instance_rows 新增 get_instance_synced_options

- 前端 GameInstance 增加 synced_options 类型
2026-10-03 21:08:05 +08:00
AxTps 91e63fdb2c fix: 登录皮肤站后自动认证默认玩家,修复创建实例误报需要登录
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
- requireHostedGameAccount 在无游戏账户但皮肤站已登录时,自动认证第一个非 Mojang 玩家

- 新增测试覆盖该自动认证流程
2026-10-03 19:14:25 +08:00
Axolotl Release Bot 214066ae5f chore(website): update download metadata to v1.0.1 [skip ci] 2026-10-03 07:08:29 +00:00
285 changed files with 27195 additions and 243 deletions

No files matched your search

+2 -2
View File
@@ -12,7 +12,7 @@ on:
permissions:
issues: write
pull-requests: read
pull-requests: write
jobs:
comment:
@@ -21,7 +21,7 @@ jobs:
permissions:
issues: write
pull-requests: read
pull-requests: write
contents: read
steps:
+1 -1
View File
@@ -22,5 +22,5 @@ jobs:
node-version-file: .nvmrc
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
- run: cargo binstall --no-confirm cargo-shear
- run: cargo binstall --no-confirm cargo-shear@1.13.4
- run: cargo shear
Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "1.0.0-beta15"
version = "1.0.2-beta4"
dependencies = [
"ariadne",
"async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "1.0.0-beta15"
version = "1.0.2-beta4"
dependencies = [
"ashpd",
"async_zip",
+2
View File
@@ -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]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.0-beta15",
"version": "1.0.2-beta4",
"type": "module",
"scripts": {
"dev": "vite",
+4
View File
@@ -72,6 +72,7 @@ import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import AnniversaryCelebration from '@/components/ui/AnniversaryCelebration.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
@@ -94,6 +95,7 @@ import NavRail from '@/components/ui/NavRail.vue'
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import RemoteAnnouncements from '@/components/ui/RemoteAnnouncements.vue'
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
@@ -2221,10 +2223,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</script>
<template>
<AnniversaryCelebration />
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<SkinSiteSessionFrame v-if="stateInitialized" />
<InstancePlayerModal v-if="stateInitialized" />
<StartupAnnouncementModal ref="startupAnnouncementModal" />
<RemoteAnnouncements :ready="stateInitialized && !showOnboarding && !stateFailed" />
<div id="teleports"></div>
<div
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
@@ -32,6 +32,102 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.2-beta4',
version: '1.0.2-beta4',
publishedAt: '2026-10-08',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta4',
'zh-CN': 'Starlight Launcher 1.0.2-beta4',
},
changes: {
added: [
{
'en-US': 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
'zh-CN': '皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
},
{
'en-US': 'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
},
],
changed: [
{
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
'zh-CN': '优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
},
],
fixed: [
{
'en-US': 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
'zh-CN': '修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
},
{
'en-US': 'Fixed instance group changes not saving for some instances.',
'zh-CN': '修复部分实例的分组修改无法保存的问题。',
},
],
},
},
{
id: 'launcher-1.0.2-beta3',
version: '1.0.2-beta3',
publishedAt: '2026-10-04',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta3',
'zh-CN': 'Starlight Launcher 1.0.2-beta3',
},
changes: {
fixed: [
{
'en-US': 'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
'zh-CN': '修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
},
],
},
},
{
id: 'launcher-1.0.2-beta2',
version: '1.0.2-beta2',
publishedAt: '2026-10-04',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta2',
'zh-CN': 'Starlight Launcher 1.0.2-beta2',
},
changes: {
fixed: [
{
'en-US': 'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
'zh-CN': '修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
},
],
},
},
{
id: 'launcher-1.0.2-beta1',
version: '1.0.2-beta1',
publishedAt: '2026-10-04',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta1',
'zh-CN': 'Starlight Launcher 1.0.2-beta1',
},
changes: {
added: [
{
'en-US': 'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
'zh-CN': '跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
},
{
'en-US': 'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
'zh-CN': 'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
},
{
'en-US': 'Added a developer feature flag to preview the anniversary celebration at any time.',
'zh-CN': '新增开发者功能开关,可随时预览周年庆效果。',
},
],
},
},
{
id: 'launcher-1.0.1',
version: '1.0.1',
@@ -0,0 +1,246 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { anniversaryYear, ordinal, shouldCelebrate } from '@/helpers/anniversary'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const messages = defineMessages({
banner: {
id: 'app.anniversary.banner',
defaultMessage: 'Happy {ordinal} Anniversary, Starlight!',
},
})
/** Emoji that drift down from the top of the window. */
const CONFETTI_EMOJI = ['🎂', '🎉', '🎊', '🎈', '✨', '🧁', '🍰']
type Confetti = {
id: number
emoji: string
left: number
delay: number
duration: number
size: number
drift: number
}
const visible = ref(false)
const years = ref(0)
const confetti = ref<Confetti[]>([])
let spawnTimer: number | undefined
let nextId = 0
function spawnConfetti(): void {
const emoji = CONFETTI_EMOJI[Math.floor(Math.random() * CONFETTI_EMOJI.length)]
confetti.value.push({
id: nextId++,
emoji,
left: Math.random() * 100,
delay: 0,
duration: 6 + Math.random() * 5,
size: 1 + Math.random() * 1.4,
drift: (Math.random() - 0.5) * 160,
})
// Keep the DOM small; drop the oldest once we exceed a cap.
if (confetti.value.length > 60) confetti.value.splice(0, 10)
}
const forced = computed(() => themeStore.getFeatureFlag('force_anniversary_celebration'))
function startCelebration(): void {
if (spawnTimer !== undefined) return
years.value = anniversaryYear()
visible.value = true
// Seed a few so the screen is not empty on the first frame.
for (let i = 0; i < 12; i++) spawnConfetti()
spawnTimer = window.setInterval(spawnConfetti, 700)
}
function stopCelebration(): void {
if (spawnTimer !== undefined) {
window.clearInterval(spawnTimer)
spawnTimer = undefined
}
confetti.value = []
visible.value = false
}
watch(
() => shouldCelebrate(forced.value),
(active) => (active ? startCelebration() : stopCelebration()),
{ immediate: true },
)
onUnmounted(stopCelebration)
const bannerText = computed(() =>
formatMessage(messages.banner, { ordinal: ordinal(years.value) }),
)
</script>
<template>
<Transition name="anniversary-fade">
<div v-if="visible" class="anniversary-root" aria-live="polite">
<!-- Falling confetti layer (click-through). -->
<div class="anniversary-confetti" aria-hidden="true">
<span
v-for="piece in confetti"
:key="piece.id"
class="anniversary-piece"
:style="{
left: piece.left + '%',
fontSize: piece.size + 'rem',
animationDelay: piece.delay + 's',
animationDuration: piece.duration + 's',
'--drift': piece.drift + 'px',
}"
>
{{ piece.emoji }}
</span>
</div>
<!-- Top banner. -->
<div class="anniversary-banner">
<span class="anniversary-banner-emoji">🎂</span>
<span class="anniversary-banner-text">{{ bannerText }}</span>
<span class="anniversary-banner-emoji">🎉</span>
</div>
</div>
</Transition>
</template>
<style scoped>
.anniversary-root {
position: fixed;
inset: 0;
/* Above content, below modals/drag overlays (which use z-[200]). */
z-index: 150;
pointer-events: none;
}
.anniversary-confetti {
position: absolute;
inset: 0;
overflow: hidden;
}
.anniversary-piece {
position: absolute;
top: -3rem;
will-change: transform;
animation-name: anniversary-fall;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
@keyframes anniversary-fall {
0% {
transform: translate3d(0, 0, 0) rotate(0deg);
opacity: 0;
}
10% {
opacity: 1;
}
100% {
transform: translate3d(var(--drift, 0), 110vh, 0) rotate(360deg);
opacity: 0.85;
}
}
.anniversary-banner {
position: absolute;
/* 避开原生窗口标题栏(Windows/macOS 系统装饰条会盖住 top:0 的内容);
titlebar-area-height 是 Tauri 暴露的标题栏高度变量,不支持时降级为 0 */
top: env(titlebar-area-height, 0px);
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.4rem 1rem;
font-weight: 700;
font-size: 0.95rem;
letter-spacing: 0.02em;
color: #fff;
/* 半透明深色兜底:即使下面的渐变背景失效,白字也不会在浅色页面上隐形 */
background-color: rgba(20, 16, 28, 0.35);
/* 半透明彩色渐变:让窗口下方的内容能透出来 */
background-image: linear-gradient(
90deg,
rgba(244, 114, 182, 0.8),
rgba(250, 204, 21, 0.8),
rgba(74, 222, 128, 0.8),
rgba(56, 189, 248, 0.8),
rgba(167, 139, 250, 0.8),
rgba(244, 114, 182, 0.8)
);
background-size: 300% 100%;
animation: anniversary-gradient 8s linear infinite;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.55),
0 0 4px rgba(0, 0, 0, 0.35);
}
.anniversary-banner-text {
animation: anniversary-pulse 1.8s ease-in-out infinite;
}
.anniversary-banner-emoji {
animation: anniversary-bounce 1.4s ease-in-out infinite;
}
@keyframes anniversary-gradient {
0% {
background-position: 0% 50%;
}
100% {
background-position: 300% 50%;
}
}
@keyframes anniversary-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
@keyframes anniversary-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
.anniversary-fade-enter-active,
.anniversary-fade-leave-active {
transition: opacity 0.6s ease;
}
.anniversary-fade-enter-from,
.anniversary-fade-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.anniversary-piece,
.anniversary-banner,
.anniversary-banner-text,
.anniversary-banner-emoji {
animation: none;
}
}
</style>
@@ -67,6 +67,23 @@
</div>
</template>
</Dropdown>
<ButtonStyled
v-tooltip="formatMessage(messages.announcements)"
type="transparent"
circular
>
<button
:aria-label="formatMessage(messages.announcements)"
class="relative"
@click="openAnnouncementCenter"
>
<NewspaperIcon />
<span
v-if="announcementUnreadCount"
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
/>
</button>
</ButtonStyled>
<ButtonStyled
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
color="brand"
@@ -190,6 +207,7 @@
<script setup lang="ts">
import {
BellIcon,
NewspaperIcon,
DownloadIcon,
DropdownIcon,
OnlineIndicatorIcon,
@@ -211,7 +229,7 @@ import {
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
@@ -222,6 +240,10 @@ import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
import {
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
} from '@/helpers/remote-announcements'
import type { GameInstance } from '@/helpers/types'
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
@@ -231,6 +253,25 @@ const popupNotificationManager = injectPopupNotificationManager()
const downloadManager = injectDownloadManager()
const { formatMessage } = useVIntl()
const announcementUnreadCount = ref(0)
function openAnnouncementCenter() {
window.dispatchEvent(new CustomEvent(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT))
}
function updateAnnouncementCount(event: Event) {
const detail = (event as CustomEvent<{ unreadKeys?: string[] }>).detail
announcementUnreadCount.value = detail?.unreadKeys?.length ?? 0
}
onMounted(() => {
window.addEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
})
onBeforeUnmount(() => {
window.removeEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
})
type NotificationHistoryItem = {
key: string
createdAt?: number
@@ -370,6 +411,10 @@ const messages = defineMessages({
id: 'app.action-bar.notifications',
defaultMessage: 'Notifications',
},
announcements: {
id: 'app.action-bar.announcements',
defaultMessage: 'Announcements',
},
clearNotifications: {
id: 'app.action-bar.notifications.clear',
defaultMessage: 'Clear all',
@@ -0,0 +1,388 @@
<script setup lang="ts">
import {
Button,
commonMessages,
defineMessages,
NewModal,
useModalStack,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import {
announcementKey,
isAnnouncementActive,
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
parseAnnouncements,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
type RemoteAnnouncement,
safeAnnouncementUrl,
} from '@/helpers/remote-announcements'
const props = defineProps<{ ready: boolean; previewOnly?: boolean }>()
const { formatMessage } = useVIntl()
const { hasModal } = useModalStack()
const messages = defineMessages({
view: { id: 'app.remote-announcements.view', defaultMessage: 'View announcement' },
unread: { id: 'app.remote-announcements.unread', defaultMessage: 'Unread' },
readAll: {
id: 'app.remote-announcements.read-all',
defaultMessage: 'Mark all announcements as read',
},
centerTitle: { id: 'app.remote-announcements.center-title', defaultMessage: 'Announcements' },
empty: { id: 'app.remote-announcements.empty', defaultMessage: 'No announcements' },
previewTitle: {
id: 'app.remote-announcements.preview-title',
defaultMessage: 'Announcement style preview',
},
previewSummary: {
id: 'app.remote-announcements.preview-summary',
defaultMessage:
'This is a local preview. Select View announcement to preview the full Markdown content.',
},
previewContent: {
id: 'app.remote-announcements.preview-content',
defaultMessage:
'## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://skin.starlight.cool/)',
},
previewAction: { id: 'app.remote-announcements.preview-action', defaultMessage: 'Visit website' },
})
const modal = ref<InstanceType<typeof NewModal>>()
const selected = ref<RemoteAnnouncement | null>(null)
const active = ref(false)
const centerOpen = ref(false)
const startupNotice = ref<RemoteAnnouncement | null>(null)
const html = computed(() => renderString(selected.value?.content ?? ''))
const stateKey = 'starlight-remote-announcements-v2'
const read = reactive(new Set<string>())
const queuedThisSession = new Set<string>()
const startupNotified = new Set<string>()
let items: RemoteAnnouncement[] = []
let pending: RemoteAnnouncement[] = []
let cacheKey = ''
let cacheLoaded = false
let disposed = false
let interval: ReturnType<typeof setInterval> | undefined
let advanceTimer: ReturnType<typeof setTimeout> | undefined
function persist() {
if (props.previewOnly) return
try {
localStorage.setItem(
stateKey,
JSON.stringify({
read: [...read].slice(-1000),
}),
)
} catch {
// localStorage may be unavailable (private mode / quota)
}
}
function emitState() {
window.dispatchEvent(
new CustomEvent(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, {
detail: {
items,
unreadKeys: items.filter((item) => !read.has(announcementKey(item))).map(announcementKey),
},
}),
)
}
function openCenter(item?: RemoteAnnouncement) {
if (!props.ready || disposed) return
centerOpen.value = true
if (item) {
startupNotice.value = null
selectAnnouncement(item)
} else if (!selected.value && items.length) {
selectAnnouncement(items[0])
}
void nextTick(() => modal.value?.show())
}
function handleOpenCenter() {
if (props.previewOnly) return
openCenter()
}
async function show(item: RemoteAnnouncement) {
if (
disposed ||
!props.ready ||
!isAnnouncementActive(item) ||
(hasModal.value && !active.value && !centerOpen.value)
)
return
centerOpen.value = true
active.value = true
selectAnnouncement(item)
await nextTick()
if (!disposed) modal.value?.show()
}
function selectAnnouncement(item: RemoteAnnouncement) {
selected.value = item
const key = announcementKey(item)
read.add(key)
pending = pending.filter((entry) => announcementKey(entry) !== key)
persist()
emitState()
}
function markAllRead() {
for (const item of items) {
read.add(announcementKey(item))
}
pending = []
persist()
emitState()
}
function advance() {
if (disposed || !props.ready || hasModal.value || active.value) return
const next = pending.shift()
if (next) {
void show(next)
return
}
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'notification' &&
!startupNotified.has(key) &&
!read.has(key) &&
isAnnouncementActive(item)
) {
startupNotice.value = item
startupNotified.add(key)
setTimeout(() => {
if (startupNotice.value === item) {
startupNotice.value = null
advance()
}
}, 8000)
break
}
}
}
function closed() {
active.value = false
centerOpen.value = false
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
}
function sync(next: RemoteAnnouncement[], fresh: boolean) {
items = next.filter((item) => isAnnouncementActive(item))
const keys = new Set(items.map(announcementKey))
pending = pending.filter((item) => keys.has(announcementKey(item)))
if (selected.value && !keys.has(announcementKey(selected.value))) selected.value = null
emitState()
if (fresh) {
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'modal' &&
!queuedThisSession.has(key) &&
(!read.has(key) || item.priority === 'critical')
) {
queuedThisSession.add(key)
pending.push(item)
}
}
advance()
}
}
function loadCache() {
if (cacheLoaded || !cacheKey) return
cacheLoaded = true
try {
const cached = JSON.parse(localStorage.getItem(cacheKey) ?? 'null')
if (cached && typeof cached.savedAt === 'number' && Date.now() - cached.savedAt < 86400000) {
const parsed = parseAnnouncements(cached.items)
if (parsed) sync(parsed, false)
}
} catch {
// Ignore malformed or expired cache payloads
}
}
async function refresh() {
// Remote fetching is disabled until Starlight has its own announcements API.
// We still load any previously cached items so the center has content to show.
if (disposed) return
loadCache()
}
async function openLink(value: unknown) {
const url = safeAnnouncementUrl(value)
if (!url) return
try {
await openUrl(url)
} catch {
// Opener may reject unknown schemes
}
}
function contentClick(event: MouseEvent) {
const link = event.target instanceof Element ? event.target.closest('a') : null
if (!link) return
event.preventDefault()
event.stopPropagation()
void openLink(link.getAttribute('href'))
}
function preview(type: RemoteAnnouncement['type'], withAction = false) {
if (!props.previewOnly || !props.ready || hasModal.value || disposed) return
const now = new Date().toISOString()
const item: RemoteAnnouncement = {
id: 'local-preview-' + type,
title: formatMessage(messages.previewTitle),
summary: formatMessage(messages.previewSummary),
content: formatMessage(messages.previewContent),
type,
priority: 'normal',
starts_at: now,
ends_at: null,
published_at: now,
action_label: withAction ? formatMessage(messages.previewAction) : null,
action_url: withAction ? 'https://skin.starlight.cool/' : null,
}
read.clear()
queuedThisSession.clear()
sync([item], true)
}
defineExpose({ preview })
onMounted(() => {
if (props.previewOnly) return
try {
const saved = JSON.parse(localStorage.getItem(stateKey) ?? 'null')
if (saved && Array.isArray(saved.read))
for (const key of saved.read) if (typeof key === 'string') read.add(key)
} catch {
// Ignore malformed local read-state
}
window.addEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
const start = () => {
if (disposed) return
void refresh()
interval = setInterval(() => {
sync(items, false)
advance()
}, 15000)
}
// Defer the startup request until after first paint.
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(start, { timeout: 2000 })
} else {
setTimeout(start, 500)
}
})
watch([() => props.ready, hasModal], () => {
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
})
onUnmounted(() => {
disposed = true
if (interval) clearInterval(interval)
if (advanceTimer) clearTimeout(advanceTimer)
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
})
</script>
<template>
<div
v-if="startupNotice"
class="fixed right-4 top-16 z-50 w-[22rem] max-w-[calc(100vw-2rem)] rounded-xl border border-surface-5 bg-surface-3 p-3 shadow-lg"
>
<button class="w-full text-left" @click="startupNotice && openCenter(startupNotice)">
<div class="mb-1 text-xs font-semibold uppercase text-brand">
{{ formatMessage(messages.unread) }}
</div>
<div class="font-semibold text-[var(--color-text-primary)]">{{ startupNotice.title }}</div>
<div
v-if="startupNotice.summary"
class="mt-1 line-clamp-2 text-sm text-[var(--color-text-tertiary)]"
>
{{ startupNotice.summary }}
</div>
</button>
</div>
<NewModal
ref="modal"
:header="formatMessage(messages.centerTitle)"
:on-hide="closed"
max-width="80vw"
width="80vw"
max-content-height="calc(80vh - 8rem)"
scrollable
>
<div class="flex h-[calc(80vh-8rem)] min-h-0 gap-3">
<aside class="flex min-h-0 w-[32%] min-w-[14rem] flex-col border-r border-surface-5 pr-3">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.centerTitle)
}}</span>
<Button
v-if="items.some((item) => !read.has(announcementKey(item)))"
@click="markAllRead"
>{{ formatMessage(messages.readAll) }}</Button
>
</div>
<div
v-if="!items.length"
class="py-8 text-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
<div v-else class="flex min-h-0 flex-col gap-1 overflow-auto">
<button
v-for="item in items"
:key="announcementKey(item)"
class="flex items-start gap-2 rounded-lg p-2 text-left hover:bg-surface-4"
:class="{
'bg-surface-4': selected && announcementKey(selected) === announcementKey(item),
}"
@click="selectAnnouncement(item)"
>
<span
class="mt-1.5 size-2 shrink-0 rounded-full"
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
item.title
}}</span>
<span class="block line-clamp-2 text-xs text-[var(--color-text-tertiary)]">{{
item.summary || item.content
}}</span>
</span>
</button>
</div>
</aside>
<section v-if="selected" class="min-h-0 min-w-0 flex-1 overflow-auto px-1">
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
{{ selected.title }}
</h2>
<div
class="markdown-body break-words"
@click="contentClick"
@auxclick="contentClick"
v-html="html"
/>
<div class="mt-4 flex justify-end">
<Button
v-if="selected.action_url && selected.action_label"
type="colored"
color="brand"
@click="openLink(selected.action_url)"
>{{ selected.action_label }}</Button
>
</div>
</section>
<div
v-else
class="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<Button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }} </Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,618 @@
<script setup lang="ts">
import { EditIcon } from '@modrinth/assets'
import {
commonMessages,
defineMessages,
IconButton,
injectNotificationManager,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onScopeDispose, ref } from 'vue'
import { gameSettingsKeys } from '@/helpers/game-options'
import {
type GlobalSyncedOptions,
isSyncedOptionAvailable,
set_global_synced_option,
type SyncedOption,
} from '@/helpers/instance'
import {
canSourceMultiplayerServers,
gameOptionsSyncSourcesQueryOptions,
globalSyncedOptionsQueryOptions,
initializedSyncedOptionsQueryOptions,
syncedOptionsKeys,
syncedServersQueryOptions,
} from '@/helpers/synced-options'
import { syncedPackKeys, syncedPackQueryOptions } from '@/helpers/synced-packs'
import { instanceKeys, instanceListQueryOptions } from '@/pages/instance/query-options'
import SettingsRow from '../settings/SettingsRow.vue'
import SettingsSection from '../settings/SettingsSection.vue'
import CommandHistoryModal from '../settings/instances/command-history-modal.vue'
import SyncedServersModal from '../settings/instances/servers-modal.vue'
import SyncedPacksModal from '../settings/instances/SyncedPacksModal.vue'
import SyncSourceModal from '../settings/instances/SyncSourceModal.vue'
import GameSettingsModal from '../settings/instances/game-settings-modal/index.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const syncedPacksModal = ref<InstanceType<typeof SyncedPacksModal>>()
const commandHistoryModal = ref<InstanceType<typeof CommandHistoryModal>>()
const syncedServersModal = ref<InstanceType<typeof SyncedServersModal>>()
const gameSettingsModal = ref<InstanceType<typeof GameSettingsModal> | null>(null)
const messages = defineMessages({
globalOptionsTitle: {
id: 'app.settings.synced-options.title',
defaultMessage: 'Options synced across all instances',
},
resourcePacks: {
id: 'app.settings.synced-options.resource-packs',
defaultMessage: 'Sync resource packs',
},
resourcePacksDescription: {
id: 'app.settings.synced-options.resource-packs.description',
defaultMessage: 'Use the same resource packs across your instances.',
},
dataPacks: { id: 'app.settings.synced-options.data-packs', defaultMessage: 'Sync data packs' },
dataPacksDescription: {
id: 'app.settings.synced-options.data-packs.description',
defaultMessage: 'Use the same datapacks across your instances',
},
packsDisabled: {
id: 'app.settings.synced-options.packs.disabled',
defaultMessage: 'Enable syncing before editing shared packs.',
},
gameSettings: {
id: 'app.settings.synced-options.game-settings',
defaultMessage: 'Sync game options',
},
gameSettingsDescription: {
id: 'app.settings.synced-options.game-settings.description',
defaultMessage: 'Use the same game options across your instances.',
},
gameSettingsButton: {
id: 'app.settings.synced-options.game-settings.button',
defaultMessage: 'Edit game settings',
},
gameSettingsDisabledTooltip: {
id: 'app.settings.synced-options.game-settings.disabled-tooltip',
defaultMessage: 'Enable game settings sync before choosing individual settings.',
},
noGameOptionsToEdit: {
id: 'app.settings.synced-options.game-settings.no-options-to-edit',
defaultMessage: "You haven't got any options yet to edit",
},
multiplayerServers: {
id: 'app.settings.synced-options.multiplayer-servers',
defaultMessage: 'Sync multiplayer servers',
},
multiplayerServersDescription: {
id: 'app.settings.synced-options.multiplayer-servers.description',
defaultMessage: 'Use the same multiplayer servers across your instances.',
},
commandHistory: {
id: 'app.settings.synced-options.command-history',
defaultMessage: 'Sync command history',
},
commandHistoryDescription: {
id: 'app.settings.synced-options.command-history.description',
defaultMessage: 'Use the same command history across your instances.',
},
creativeHotbars: {
id: 'app.settings.synced-options.creative-hotbars',
defaultMessage: 'Sync saved creative hotbars',
},
creativeHotbarsDescription: {
id: 'app.settings.synced-options.creative-hotbars.description',
defaultMessage: 'Use the same saved creative hotbars across your instances.',
},
gameSettingsSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.game-settings-title',
defaultMessage: 'Choose game settings source',
},
multiplayerServersSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-description',
defaultMessage:
'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.',
},
multiplayerServersSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-title',
defaultMessage: 'Choose multiplayer servers source',
},
commandHistorySyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.command-history-description',
defaultMessage:
'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.',
},
commandHistorySyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.command-history-title',
defaultMessage: 'Choose command history source',
},
creativeHotbarsSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-description',
defaultMessage:
'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.',
},
creativeHotbarsSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-title',
defaultMessage: 'Choose creative hotbars source',
},
gameSettingsSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.game-settings-description',
defaultMessage:
'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.',
},
resourcePacksSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.resource-packs-title',
defaultMessage: 'Choose resource packs source',
},
resourcePacksSyncSourceDescription: {
id: 'app.sync-instances-update.choose-source.resource-packs',
defaultMessage:
'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.',
},
dataPacksSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.data-packs-title',
defaultMessage: 'Choose data packs source',
},
dataPacksSyncSourceDescription: {
id: 'app.sync-instances-update.choose-source.data-packs',
defaultMessage:
'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.',
},
noServersSyncedYet: {
id: 'app.settings.synced-options.multiplayer-servers.none-synced-yet',
defaultMessage: "You haven't synced any servers yet",
},
noResourcePacksSyncedYet: {
id: 'app.settings.synced-options.resource-packs.none-synced-yet',
defaultMessage: "You haven't synced any resource packs yet",
},
noDataPacksSyncedYet: {
id: 'app.settings.synced-options.data-packs.none-synced-yet',
defaultMessage: "You haven't synced any data packs yet",
},
noInstancesToEdit: {
id: 'app.settings.synced-options.edit.no-instances',
defaultMessage: 'Add an instance before editing synced data.',
},
noSyncedDataToEdit: {
id: 'app.settings.synced-options.edit.no-synced-data',
defaultMessage: 'Choose a sync source before editing this setting.',
},
})
const globalRows: Array<{
option: SyncedOption
title: keyof typeof messages
description?: keyof typeof messages
editable?: 'game-settings' | 'servers' | 'commands' | 'resourcepack' | 'datapack'
}> = [
{
option: 'game_options',
title: 'gameSettings',
description: 'gameSettingsDescription',
editable: 'game-settings',
},
{
option: 'multiplayer_servers',
title: 'multiplayerServers',
description: 'multiplayerServersDescription',
editable: 'servers',
},
{
option: 'resource_packs',
title: 'resourcePacks',
description: 'resourcePacksDescription',
editable: 'resourcepack',
},
{
option: 'data_packs',
title: 'dataPacks',
description: 'dataPacksDescription',
editable: 'datapack',
},
{
option: 'command_history',
title: 'commandHistory',
description: 'commandHistoryDescription',
editable: 'commands',
},
{
option: 'creative_hotbars',
title: 'creativeHotbars',
description: 'creativeHotbarsDescription',
},
]
const availableGlobalRows = globalRows.filter((row) => isSyncedOptionAvailable(row.option))
const defaultGlobalOptions: GlobalSyncedOptions = {
resource_packs: false,
data_packs: false,
game_options: false,
command_history: false,
multiplayer_servers: false,
creative_hotbars: false,
screenshots: false,
}
const globalOptionsQuery = useQuery(globalSyncedOptionsQueryOptions())
const initializedOptionsQuery = useQuery(initializedSyncedOptionsQueryOptions())
const gameOptionSourcesQuery = useQuery(gameOptionsSyncSourcesQueryOptions())
const resourcePacksQuery = useQuery(syncedPackQueryOptions('resourcepack'))
const dataPacksQuery = useQuery(syncedPackQueryOptions('datapack'))
const globalOptions = computed(() => globalOptionsQuery.data.value ?? defaultGlobalOptions)
const initializedOptions = computed(
() => initializedOptionsQuery.data.value ?? defaultGlobalOptions,
)
const gameOptionSources = computed(() => gameOptionSourcesQuery.data.value ?? [])
const instancesQuery = useQuery(instanceListQueryOptions())
const instances = computed(() => instancesQuery.data.value ?? [])
const serversQuery = useQuery(syncedServersQueryOptions())
const syncedServers = computed(() => serversQuery.data.value ?? [])
const eligibleGameOptionSourceIds = computed(
() =>
new Set(
gameOptionSources.value.filter((source) => source.eligible).map((source) => source.source_id),
),
)
const hasGameOptionsToEdit = computed(
() =>
initializedOptions.value.game_options ||
instances.value.some(
(instance) =>
instance?.synced_options?.game_options &&
eligibleGameOptionSourceIds.value.has(instance.id),
),
)
const baseOption = ref<SyncedOption | null>(null)
const baseInstanceId = ref('')
const baseModal = ref<InstanceType<typeof SyncSourceModal> | null>(null)
const baseSourcesLoading = computed(() =>
baseOption.value === null
? false
: baseOption.value === 'game_options'
? gameOptionSourcesQuery.isPending.value
: instancesQuery.isPending.value,
)
const baseSourcesError = computed(() =>
baseOption.value === 'game_options'
? gameOptionSourcesQuery.isError.value && !gameOptionSourcesQuery.data.value
: instancesQuery.isError.value && !instancesQuery.data.value,
)
let baseSourceGeneration = 0
const baseInstanceTitle = computed(() => {
switch (baseOption.value) {
case 'game_options':
return formatMessage(messages.gameSettingsSyncSourceTitle)
case 'multiplayer_servers':
return formatMessage(messages.multiplayerServersSyncSourceTitle)
case 'command_history':
return formatMessage(messages.commandHistorySyncSourceTitle)
case 'creative_hotbars':
return formatMessage(messages.creativeHotbarsSyncSourceTitle)
case 'resource_packs':
return formatMessage(messages.resourcePacksSyncSourceTitle)
case 'data_packs':
return formatMessage(messages.dataPacksSyncSourceTitle)
default:
return ''
}
})
const baseInstanceDescription = computed(() => {
switch (baseOption.value) {
case 'game_options':
return formatMessage(messages.gameSettingsSyncSourceDescription)
case 'multiplayer_servers':
return formatMessage(messages.multiplayerServersSyncSourceDescription)
case 'command_history':
return formatMessage(messages.commandHistorySyncSourceDescription)
case 'creative_hotbars':
return formatMessage(messages.creativeHotbarsSyncSourceDescription)
case 'resource_packs':
return formatMessage(messages.resourcePacksSyncSourceDescription)
case 'data_packs':
return formatMessage(messages.dataPacksSyncSourceDescription)
default:
return ''
}
})
const baseInstances = computed(() =>
baseOption.value === 'game_options'
? gameOptionSources.value.map((source) => ({
id: source.source_id,
name: source.name,
icon_path: source.icon_path,
eligible: source.eligible,
}))
: instances.value.map((instance) => ({
id: instance.id,
name: instance.name,
icon_path: instance.icon_path,
eligible:
!instance.quarantined &&
(baseOption.value !== 'multiplayer_servers' || canSourceMultiplayerServers(instance)),
})),
)
async function invalidateSyncedOptions() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.gameSources }),
queryClient.invalidateQueries({ queryKey: ['worlds'] }),
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
])
}
function emptySyncedContentMessage(row: (typeof globalRows)[number]) {
if (
row.editable === 'servers' &&
(instances.value.length === 0 || syncedServers.value.length === 0)
) {
return messages.noServersSyncedYet
}
if (row.editable === 'resourcepack' && !resourcePacksQuery.data.value?.length) {
return messages.noResourcePacksSyncedYet
}
if (row.editable === 'datapack' && !dataPacksQuery.data.value?.length) {
return messages.noDataPacksSyncedYet
}
}
function canEditGlobalOption(row: (typeof globalRows)[number]): boolean {
if (!row.editable || !globalOptions.value[row.option]) return false
if (emptySyncedContentMessage(row)) return false
if (row.editable === 'resourcepack' || row.editable === 'datapack') return true
if (row.editable === 'game-settings') return hasGameOptionsToEdit.value
return instances.value.length > 0 && initializedOptions.value[row.option]
}
function editGlobalOptionTooltip(row: (typeof globalRows)[number]): string {
const emptyMessage = emptySyncedContentMessage(row)
if (emptyMessage) return formatMessage(emptyMessage)
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
return formatMessage(
globalOptions.value[row.option] ? commonMessages.editButton : messages.packsDisabled,
)
}
if (row.editable === 'game-settings') {
if (!hasGameOptionsToEdit.value) return formatMessage(messages.noGameOptionsToEdit)
if (!globalOptions.value[row.option]) {
return formatMessage(messages.gameSettingsDisabledTooltip)
}
return formatMessage(messages.gameSettingsButton)
}
if (instances.value.length === 0) {
return formatMessage(messages.noInstancesToEdit)
}
if (!initializedOptions.value[row.option]) {
return formatMessage(messages.noSyncedDataToEdit)
}
return formatMessage(commonMessages.editButton)
}
type GlobalOptionMutationVariables = {
option: SyncedOption
enabled: boolean
baseInstanceId?: string
}
const globalOptionMutation = useMutation({
mutationKey: syncedOptionsKeys.set,
mutationFn: ({ option, enabled, baseInstanceId }: GlobalOptionMutationVariables) =>
set_global_synced_option(option, enabled, baseInstanceId),
onError: handleError,
onSuccess: async (options, { option, enabled }) => {
queryClient.setQueryData(syncedOptionsKeys.global, options)
if (option === 'game_options') {
await queryClient.invalidateQueries({ queryKey: gameSettingsKeys.synced })
}
if (enabled && option === 'multiplayer_servers') {
await queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.servers })
}
},
onSettled: async (_data, _error, { option }) => {
if (queryClient.isMutating({ mutationKey: syncedOptionsKeys.set }) === 1) {
if (option === 'resource_packs' || option === 'data_packs') {
void Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
])
return
}
await invalidateSyncedOptions()
}
},
})
const canToggleGlobalOptions = computed(
() =>
!!globalOptionsQuery.data.value &&
!globalOptionMutation.isPending.value &&
!baseSourcesLoading.value,
)
async function chooseBaseInstance(option: SyncedOption) {
const generation = ++baseSourceGeneration
baseOption.value = option
baseInstanceId.value = ''
try {
if (option === 'game_options') {
await queryClient.fetchQuery(gameOptionsSyncSourcesQueryOptions())
} else {
await queryClient.fetchQuery({ ...instanceListQueryOptions(), staleTime: 0 })
}
} catch (error) {
if (generation !== baseSourceGeneration) return
handleError(error)
baseModal.value?.show()
return
}
if (generation !== baseSourceGeneration) return
const eligibleSources = baseInstances.value.filter((source) => source.eligible)
baseInstanceId.value = eligibleSources[0]?.id ?? ''
if (eligibleSources.length > 1) {
baseModal.value?.show()
return
}
try {
await globalOptionMutation.mutateAsync({
option,
enabled: true,
baseInstanceId: eligibleSources[0]?.id,
})
await nextTick()
if (!baseModal.value?.hide()) clearBaseSource()
} catch {
return
}
}
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
if (!isSyncedOptionAvailable(option) || !canToggleGlobalOptions.value) return
if (enabled && option !== 'screenshots') {
void chooseBaseInstance(option)
return
}
globalOptionMutation.mutate({ option, enabled })
}
async function confirmBaseInstance() {
if (!baseOption.value || !baseInstanceId.value) return
try {
await globalOptionMutation.mutateAsync({
option: baseOption.value,
enabled: true,
baseInstanceId: baseInstanceId.value,
})
await nextTick()
baseModal.value?.hide()
} catch {
return
}
}
function editGlobalOption(row: (typeof globalRows)[number]) {
if (!isSyncedOptionAvailable(row.option)) return
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
void syncedPacksModal.value?.show(row.editable)
} else if (row.editable === 'game-settings') {
openGameSettings()
} else if (row.editable === 'commands') {
void commandHistoryModal.value?.show()
} else {
void syncedServersModal.value?.show()
}
}
function openGameSettings() {
if (!hasGameOptionsToEdit.value) return
gameSettingsModal.value?.show()
}
async function handleGameSettingsSaved() {
await invalidateSyncedOptions()
}
function clearBaseSource() {
baseSourceGeneration++
baseOption.value = null
}
onScopeDispose(clearBaseSource)
</script>
<template>
<div class="flex flex-col gap-6">
<SyncedPacksModal ref="syncedPacksModal" />
<CommandHistoryModal ref="commandHistoryModal" />
<SyncedServersModal ref="syncedServersModal" />
<GameSettingsModal ref="gameSettingsModal" @saved="handleGameSettingsSaved" />
<SyncSourceModal
ref="baseModal"
v-model="baseInstanceId"
:title="baseInstanceTitle"
:description="baseInstanceDescription"
:sources="baseInstances"
:loading="baseSourcesLoading"
:error="baseSourcesError"
:pending="globalOptionMutation.isPending.value"
@confirm="confirmBaseInstance"
@close="clearBaseSource"
@retry="baseOption && chooseBaseInstance(baseOption)"
/>
<SettingsSection>
<template #header>
<h2
id="settings-target-instance-sync-options"
tabindex="-1"
class="m-0 text-lg font-semibold text-[var(--color-text-primary)]"
>
{{ formatMessage(messages.globalOptionsTitle) }}
</h2>
</template>
<SettingsRow v-for="row in availableGlobalRows" :key="row.option">
<template #label>
<span :id="`settings-target-instance-sync-${row.option}`" tabindex="-1">
{{ formatMessage(messages[row.title]) }}
</span>
</template>
<template v-if="row.description" #description>
{{ formatMessage(messages[row.description]) }}
</template>
<template #control>
<span v-if="row.editable" v-tooltip="editGlobalOptionTooltip(row)" class="flex">
<IconButton
type="outlined"
circular
:disabled="
!canEditGlobalOption(row) ||
initializedOptionsQuery.isPending.value ||
globalOptionMutation.isPending.value
"
:label="
formatMessage(
row.editable === 'game-settings'
? messages.gameSettingsButton
: commonMessages.editButton,
)
"
@click="editGlobalOption(row)"
>
<EditIcon aria-hidden="true" />
</IconButton>
</span>
<Toggle
:id="`global-sync-${row.option}`"
:model-value="globalOptions[row.option]"
:disabled="!canToggleGlobalOptions"
:aria-label="formatMessage(messages[row.title])"
@update:model-value="(enabled) => toggleGlobalOption(row.option, enabled)"
/>
</template>
</SettingsRow>
</SettingsSection>
</div>
</template>
@@ -36,24 +36,29 @@ const fullscreenSetting: Ref<boolean> = ref(
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
)
const maximizeWindowSetting = ref(instance.value.maximize_window ?? globalSettings.maximize_window)
const windowTitle = ref(instance.value.window_title ?? '')
const editInstanceObject = computed(() => {
const title = windowTitle.value.trim() ? windowTitle.value.trim() : null
if (!overrideWindowSettings.value) {
return {
force_fullscreen: null,
maximize_window: null,
game_resolution: null,
// The title is independent of the window-size override checkbox.
window_title: title,
}
}
return {
force_fullscreen: fullscreenSetting.value,
maximize_window: maximizeWindowSetting.value,
game_resolution: fullscreenSetting.value ? null : resolution.value,
window_title: title,
}
})
watch(
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting],
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting, windowTitle],
async () => {
await edit(instance.value.id, editInstanceObject.value)
},
@@ -109,6 +114,18 @@ const messages = defineMessages({
id: 'instance.settings.tabs.window.height.enter',
defaultMessage: 'Enter height...',
},
windowTitle: {
id: 'instance.settings.tabs.window.window-title',
defaultMessage: 'Window title',
},
windowTitleDescription: {
id: 'instance.settings.tabs.window.window-title.description',
defaultMessage: 'Customize the Minecraft window title. Leave empty to keep the default.',
},
enterWindowTitle: {
id: 'instance.settings.tabs.window.window-title.enter',
defaultMessage: 'Enter window title...',
},
})
</script>
@@ -200,5 +217,25 @@ const messages = defineMessages({
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
<div
v-if="globalSettings.custom_window_title_enabled"
class="flex items-center gap-4 justify-between"
>
<div class="flex flex-col gap-1">
<h2 class="m-0 inline-flex items-center gap-2 text-lg font-semibold text-contrast">
{{ formatMessage(messages.windowTitle) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.windowTitleDescription) }}
</p>
</div>
<StyledInput
id="window-title"
v-model="windowTitle"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.enterWindowTitle)"
/>
</div>
</div>
</template>
@@ -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<InstanceType<typeof TabbedModal> | null>(null)
function hide() {
function hide(onAfterClose?: () => void) {
tabbedModal.value?.hide()
if (onAfterClose) nextTick(onAfterClose)
}
provideInstanceSettings({
@@ -147,6 +150,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: CodeIcon,
content: HooksSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.sync',
defaultMessage: 'Sync',
}),
icon: RefreshCwIcon,
content: SyncSettings,
},
])
function getSupportedModpackLoaders() {
@@ -38,6 +38,26 @@ const messages = defineMessages({
id: 'app.settings.defaults.maximize-window.unsupported',
defaultMessage: 'Not supported on this operating system.',
},
windowTitleEnabled: {
id: 'app.settings.defaults.window-title.enabled',
defaultMessage: 'Custom Minecraft window title',
},
windowTitleEnabledDescription: {
id: 'app.settings.defaults.window-title.enabled-description',
defaultMessage: 'Allow instances to customize the Minecraft window title.',
},
defaultWindowTitle: {
id: 'app.settings.defaults.window-title.default',
defaultMessage: 'Default window title',
},
defaultWindowTitleDescription: {
id: 'app.settings.defaults.window-title.default-description',
defaultMessage: 'Used when an instance leaves its window title empty.',
},
defaultWindowTitlePlaceholder: {
id: 'app.settings.defaults.window-title.default-placeholder',
defaultMessage: 'Enter default window title...',
},
width: { id: 'app.settings.defaults.width', defaultMessage: 'Width' },
widthDescription: {
id: 'app.settings.defaults.width-description',
@@ -212,6 +232,36 @@ watch(
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-defaults-window-title" tabindex="-1">
{{ formatMessage(messages.windowTitleEnabled) }}
</span>
</template>
<template #description>{{
formatMessage(messages.windowTitleEnabledDescription)
}}</template>
<template #control>
<Toggle id="custom-window-title-enabled" v-model="settings.custom_window_title_enabled" />
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.defaultWindowTitle) }}</template>
<template #description>{{
formatMessage(messages.defaultWindowTitleDescription)
}}</template>
<template #control>
<StyledInput
id="default-window-title"
v-model="settings.default_window_title"
:disabled="!settings.custom_window_title_enabled"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.defaultWindowTitlePlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
@@ -71,6 +71,7 @@ const featureFlagLabels: Record<FeatureFlag, string> = {
advanced_filters_collapsed: '高级筛选默认折叠',
multiplayer_in_app: '多人联机',
auto_install_dependencies: '自动安装依赖',
force_anniversary_celebration: '强制显示周年庆庆祝(调试)',
}
const settings = ref(await getSettings())
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { RefreshCwIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
CheckCircleButton,
commonMessages,
defineMessages,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import { getInstanceIconUrl } from '@/helpers/instance'
const props = defineProps<{
title: string
description: string
sources: {
id: string
name: string
icon_path?: string | null
eligible: boolean
}[]
loading?: boolean
pending?: boolean
error?: boolean
}>()
const emit = defineEmits<{
confirm: []
close: []
retry: []
}>()
const selectedInstanceId = defineModel<string>({ required: true })
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const search = ref('')
const { formatMessage } = useVIntl()
const messages = defineMessages({
ineligible: {
id: 'app.settings.synced-options.choose-sync-source.ineligible',
defaultMessage: 'You cannot choose this instance as a sync source',
},
search: {
id: 'app.settings.synced-options.choose-sync-source.search-placeholder',
defaultMessage: 'Search instance',
},
empty: {
id: 'app.settings.synced-options.choose-sync-source.no-instances-found',
defaultMessage: 'No instances found',
},
sync: {
id: 'app.settings.synced-options.choose-sync-source.sync',
defaultMessage: 'Sync',
},
loadError: {
id: 'app.settings.synced-options.choose-sync-source.load-error',
defaultMessage: 'Could not load sync sources. Please try again.',
},
retry: {
id: 'app.settings.synced-options.choose-sync-source.retry',
defaultMessage: 'Try again',
},
})
const filteredSources = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
return props.sources.filter((source) => source.name.toLocaleLowerCase().includes(query))
})
const canConfirm = computed(
() =>
!props.loading &&
!props.pending &&
!props.error &&
props.sources.some((source) => source.id === selectedInstanceId.value && source.eligible),
)
function show() {
search.value = ''
modal.value?.show()
}
function hide() {
return modal.value?.hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
:header="title"
no-padding
actions-divider
max-width="560px"
width="560px"
:disable-close="pending"
:on-after-hide="() => emit('close')"
>
<p
class="m-0 border-0 border-b border-solid border-surface-5 p-6 text-[var(--color-text-default)]"
>
{{ description }}
</p>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 px-6 py-4">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="search"
autocomplete="off"
:placeholder="formatMessage(messages.search)"
:aria-label="formatMessage(messages.search)"
class="shrink-0"
/>
<div v-if="loading" class="flex flex-1 items-center justify-center" aria-busy="true">
<SpinnerIcon class="size-5 animate-spin text-[var(--color-text-tertiary)]" />
</div>
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-3">
<p role="alert" class="m-0 text-center text-[var(--color-text-default)]">
{{ formatMessage(messages.loadError) }}
</p>
<Button @click="emit('retry')">{{ formatMessage(messages.retry) }}</Button>
</div>
<div
v-else-if="filteredSources.length === 0"
class="flex flex-1 items-center justify-center text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
<div v-else role="radiogroup" :aria-label="title" class="flex flex-col gap-1">
<div
v-for="source in filteredSources"
:key="source.id"
v-tooltip="!source.eligible ? formatMessage(messages.ineligible) : undefined"
>
<CheckCircleButton
:checked="selectedInstanceId === source.id"
:disabled="pending || !source.eligible"
class="min-h-10"
@click="selectedInstanceId = source.id"
>
<span class="size-5 shrink-0 overflow-hidden rounded-[6px]">
<Avatar
:src="getInstanceIconUrl(source.icon_path)"
:alt="source.name"
:tint-by="source.id"
size="1.25rem"
no-shadow
/>
</span>
<span class="min-w-0 flex-1 truncate">{{ source.name }}</span>
</CheckCircleButton>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2 p-2">
<Button type="outlined" :disabled="pending" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!canConfirm"
:loading="pending"
@click="canConfirm && emit('confirm')"
>
<RefreshCwIcon :class="{ 'animate-spin': pending }" aria-hidden="true" />
{{ formatMessage(messages.sync) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { Button, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import {
remove_synced_pack,
set_synced_pack_enabled,
syncedPackKeys,
syncedPackQueryOptions,
type SyncedPackType,
} from '@/helpers/synced-packs'
const modal = ref<InstanceType<typeof NewModal>>()
const type = ref<SyncedPackType>('resourcepack')
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const messages = defineMessages({
resourcePacks: {
id: 'app.settings.synced-packs.resource-packs',
defaultMessage: 'Resource packs',
},
dataPacks: { id: 'app.settings.synced-packs.data-packs', defaultMessage: 'Data packs' },
enable: { id: 'app.settings.synced-packs.enable', defaultMessage: 'Enable' },
disable: { id: 'app.settings.synced-packs.disable', defaultMessage: 'Disable' },
remove: { id: 'app.settings.synced-packs.remove', defaultMessage: 'Remove' },
empty: { id: 'app.settings.synced-packs.empty', defaultMessage: 'No synced packs' },
})
const query = useQuery(computed(() => syncedPackQueryOptions(type.value)))
const packs = computed(() => query.value.data.value ?? [])
const mutation = useMutation({
mutationFn: async (request: { id: string; enabled?: boolean; remove?: boolean }) => {
if (request.remove) await remove_synced_pack(request.id)
else await set_synced_pack_enabled(request.id, request.enabled ?? true)
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
})
async function show(nextType: SyncedPackType) {
type.value = nextType
modal.value?.show()
await queryClient.invalidateQueries({ queryKey: syncedPackKeys.list(nextType) })
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(type === 'resourcepack' ? messages.resourcePacks : messages.dataPacks)"
max-width="42rem"
>
<div v-if="packs.length" class="flex flex-col gap-2">
<div
v-for="pack in packs"
:key="pack.id"
class="flex items-center gap-3 border-b border-solid border-surface-4 py-2"
>
<span class="min-w-0 flex-1 truncate">{{ pack.file_name }}</span>
<Button
:disabled="mutation.isPending.value"
@click="mutation.mutate({ id: pack.id, enabled: !pack.enabled })"
>{{ formatMessage(pack.enabled ? messages.disable : messages.enable) }}
</Button>
<Button
type="colored"
color="red"
:disabled="mutation.isPending.value"
@click="mutation.mutate({ id: pack.id, remove: true })"
>{{ formatMessage(messages.remove) }}
</Button>
</div>
</div>
<p v-else class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.empty) }}</p>
</NewModal>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { nextTick, ref } from 'vue'
import StudioEditor from '@/components/instance/studio/StudioEditor.vue'
import { set_command_history } from '@/helpers/instance'
import { commandHistoryQueryOptions, syncedOptionsKeys } from '@/helpers/synced-options'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const commandHistory = ref('')
const historyQuery = useQuery({ ...commandHistoryQueryOptions(), enabled: false })
const saveMutation = useMutation({
mutationFn: set_command_history,
onSuccess: (history) => {
queryClient.setQueryData(syncedOptionsKeys.commandHistory, history)
},
onError: handleError,
})
const messages = defineMessages({
commandHistoryEditorTitle: {
id: 'app.settings.synced-options.command-history.editor-title',
defaultMessage: 'Edit command history',
},
})
async function show() {
const result = await historyQuery.refetch()
if (result.isError) {
handleError(result.error)
return
}
if (!result.isSuccess) return
commandHistory.value = result.data
modal.value?.show()
}
async function saveCommandHistory() {
if (saveMutation.isPending.value) return
try {
await saveMutation.mutateAsync(commandHistory.value)
await nextTick()
modal.value?.hide()
} catch {
return
}
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.commandHistoryEditorTitle)"
:disable-close="saveMutation.isPending.value"
no-padding
actions-divider
max-width="700px"
width="700px"
>
<StudioEditor
v-model:content="commandHistory"
file-path="command_history.txt"
language="plaintext"
class="h-[420px] text-sm"
/>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" :loading="saveMutation.isPending.value" @click="saveCommandHistory">
<SaveIcon aria-hidden="true" />
{{ formatMessage(commonMessages.saveButton) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { Button } from '@modrinth/ui'
defineProps<{
modelValue?: boolean
disabled?: boolean
label: string
onLabel: string
offLabel: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:model-value': [value: boolean]
}>()
</script>
<template>
<div
v-tooltip="modelValue === undefined ? placeholder : undefined"
role="group"
:aria-label="label"
class="inline-flex h-9 items-center gap-0.5 rounded-xl border border-solid border-surface-5 p-px shadow-[0_1px_3px_rgba(0,0,0,0.15)]"
:class="{ 'opacity-50': disabled }"
>
<Button
type="quiet"
size="sm"
:interaction="modelValue === true ? 'none' : 'surface'"
class="boolean-control-on !rounded-[10px] !px-3 !font-medium disabled:!opacity-100"
:class="modelValue === true ? '!text-green' : '!text-[var(--color-text-primary)]'"
:aria-pressed="modelValue === true"
:disabled="disabled"
@click="emit('update:model-value', true)"
>
{{ onLabel }}
</Button>
<Button
type="quiet"
size="sm"
:interaction="modelValue === false ? 'none' : 'surface'"
class="boolean-control-off !rounded-[10px] !px-3 !font-medium !text-[var(--color-text-primary)] disabled:!opacity-100"
:aria-pressed="modelValue === false"
:disabled="disabled"
@click="emit('update:model-value', false)"
>
{{ offLabel }}
</Button>
</div>
</template>
<style scoped>
.boolean-control-on[aria-pressed='true'] {
background-color: color-mix(in srgb, var(--color-green) 30%, var(--surface-3));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-green) 60%, transparent);
}
.boolean-control-off[aria-pressed='true'] {
background-color: var(--surface-4);
box-shadow: inset 0 0 0 1px var(--surface-5);
}
</style>
@@ -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<string> = 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)
)
}
@@ -0,0 +1,483 @@
<script setup lang="ts">
import {
EyeIcon,
LanguagesIcon,
LinkIcon,
MessageIcon,
MonitorIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
SettingsIcon,
ShirtIcon,
TagCategoryAudioIcon,
TagCategoryGamepad2Icon,
UnlinkIcon,
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
FloatingActionBar,
StyledInput,
TabbedModal,
type TabbedModalTab,
useVIntl,
} from '@modrinth/ui'
import type { Component } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { type RouteLocationRaw, useRouter } from 'vue-router'
import type { EditableGameSetting, GameSettingCategory } from '@/helpers/game-options'
import { injectInstanceSettings } from '@/pages/instance/components/settings-modal/instance-settings-context'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
import {
canonicalValueText,
isKeybindSetting,
settingCanBeEnabled,
settingSearchText,
} from './editors'
import { minecraftKeybindConflictKey } from './keybinds'
import {
formatGameSettingDescription,
formatGameSettingLabel,
gameSettingCategoryMessage,
} from './messages'
import GameSettingRow from './row.vue'
import { useGameSettingsEditor } from './use-editor'
import { useGameSettingLabels } from './use-labels'
const props = defineProps<{
instanceId?: string
}>()
const emit = defineEmits<{
saved: []
}>()
const { formatMessage } = useVIntl()
const router = useRouter()
const appSettingsModal = inject(appSettingsModalContextKey, null)
const instanceSettings = injectInstanceSettings(null)
const messages = defineMessages({
title: {
id: 'app.settings.synced-options.game-settings.title',
defaultMessage: 'Game settings',
},
syncTitle: {
id: 'app.settings.synced-options.game-settings.sync-title',
defaultMessage: 'Sync game settings',
},
syncedOptionsDescription: {
id: 'app.settings.synced-options.game-settings.sync-description',
defaultMessage:
'Synced options are applied to every instance and override the values included with a modpack.',
},
search: {
id: 'app.settings.synced-options.game-settings.search',
defaultMessage: 'Search settings...',
},
enableAll: {
id: 'app.settings.synced-options.game-settings.enable-all',
defaultMessage: 'Sync all',
},
disableAll: {
id: 'app.settings.synced-options.game-settings.disable-all',
defaultMessage: 'Unsync all',
},
customEmpty: {
id: 'app.settings.synced-options.game-settings.custom-empty',
defaultMessage:
'Settings added by mods will appear here after Modrinth finds them in one of your instances.',
},
empty: {
id: 'app.settings.synced-options.game-settings.empty',
defaultMessage: 'No settings match your search or filter.',
},
loading: {
id: 'app.settings.synced-options.game-settings.loading',
defaultMessage: 'Loading settings...',
},
loadFailed: {
id: 'app.settings.synced-options.game-settings.load-failed',
defaultMessage: 'We couldn’t load your game settings.',
},
retry: {
id: 'app.settings.synced-options.game-settings.retry',
defaultMessage: 'Retry',
},
saveSettings: {
id: 'app.settings.synced-options.game-settings.save',
defaultMessage: 'Save settings',
},
unsavedChanges: {
id: 'app.settings.synced-options.game-settings.unsaved-changes',
defaultMessage: 'You have unsaved changes.',
},
})
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const activeCategoryId = ref('')
const search = ref('')
const opened = ref(false)
let allowClose = false
const {
draftState,
isLocalEditor,
isDirty,
hasBlockingDraft,
loading,
loadError,
saving,
load,
reset: resetEditor,
cancelChanges,
setSyncEnabled,
setCanonicalValue,
save,
saveAndWait,
} = useGameSettingsEditor(
() => props.instanceId,
() => emit('saved'),
)
const categoryIcons: Record<string, Component> = {
skin_customization: ShirtIcon,
video: MonitorIcon,
video_settings: MonitorIcon,
language: LanguagesIcon,
music_and_sound: TagCategoryAudioIcon,
controls: TagCategoryGamepad2Icon,
chat: MessageIcon,
chat_settings: MessageIcon,
accessibility: EyeIcon,
custom: WrenchIcon,
custom_settings: WrenchIcon,
}
const localeLabels = useGameSettingLabels(
opened,
() => props.instanceId,
() => draftState.value?.settings ?? [],
)
function settingLabel(setting: EditableGameSetting) {
if (setting.kind === 'external') {
return (
localeLabels.value[setting.option_id]?.label ?? formatGameSettingLabel(formatMessage, setting)
)
}
return formatGameSettingLabel(formatMessage, setting)
}
const categories = computed<GameSettingCategory[]>(() => {
if (!draftState.value) return []
const settings = draftState.value.settings
const visible = draftState.value.categories.filter(
(category) =>
category.is_custom || settings.some((setting) => setting.category_id === category.id),
)
if (!visible.some((category) => category.is_custom || category.id === 'custom_settings')) {
visible.push({
id: 'custom_settings',
is_custom: true,
})
}
return visible
})
const categoryTabs = computed<TabbedModalTab[]>(() =>
categories.value.map((category) => ({
name: gameSettingCategoryMessage(category),
icon: categoryIcon(category),
})),
)
const activeCategory = computed(() =>
categories.value.find((category) => category.id === activeCategoryId.value),
)
const categorySettings = computed(() => {
if (!draftState.value) return []
const query = search.value.trim().toLocaleLowerCase()
return draftState.value.settings.filter((setting) => {
if (!query && setting.category_id !== activeCategoryId.value) return false
if (
query &&
!settingSearchText(
setting,
settingLabel(setting),
setting.kind === 'external'
? (localeLabels.value[setting.option_id]?.source?.project?.title ??
localeLabels.value[setting.option_id]?.source?.file_name ??
'')
: formatGameSettingDescription(formatMessage, setting),
).includes(query)
)
return false
return true
})
})
const keybindConflicts = computed(() => {
if (!draftState.value) return new Map<string, string[]>()
const bindings = new Map<string, EditableGameSetting[]>()
for (const setting of draftState.value.settings) {
if (!isKeybindSetting(setting)) continue
const key = minecraftKeybindConflictKey(setting.option_id, canonicalValueText(setting))
if (!key) continue
bindings.set(key, [...(bindings.get(key) ?? []), setting])
}
const conflicts = new Map<string, string[]>()
for (const settings of bindings.values()) {
if (settings.length < 2) continue
for (const setting of settings) {
conflicts.set(
setting.option_id,
settings
.filter((candidate) => candidate.option_id !== setting.option_id)
.map((candidate) => settingLabel(candidate)),
)
}
}
return conflicts
})
const enableCandidates = computed(() =>
categorySettings.value.filter((setting) => !setting.sync_enabled && settingCanBeEnabled(setting)),
)
const disableCandidates = computed(() =>
categorySettings.value.filter((setting) => setting.sync_enabled && !setting.controlled),
)
const toggleableCategorySettings = computed(() =>
categorySettings.value.filter((setting) => !setting.controlled),
)
const allCategorySettingsSynced = computed(
() =>
toggleableCategorySettings.value.length > 0 &&
toggleableCategorySettings.value.every((setting) => setting.sync_enabled),
)
const modalTitle = computed(() =>
formatMessage(isLocalEditor.value ? messages.title : messages.syncTitle),
)
function categoryIcon(category: GameSettingCategory): Component {
return categoryIcons[category.id] ?? SettingsIcon
}
function changeCategory(_fromIndex: number, toIndex: number): boolean {
const category = categories.value[toIndex]
if (!category) return false
activeCategoryId.value = category.id
search.value = ''
return true
}
watch(
categories,
() => {
if (!categories.value.some((category) => category.id === activeCategoryId.value)) {
activeCategoryId.value = categories.value[0]?.id ?? 'custom_settings'
}
const index = categories.value.findIndex((category) => category.id === activeCategoryId.value)
if (index >= 0) modal.value?.setTab(index)
},
{ flush: 'sync' },
)
function show() {
opened.value = true
allowClose = false
search.value = ''
void load()
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function reset() {
opened.value = false
resetEditor()
allowClose = false
}
function beforeHide(): boolean {
if (allowClose || !isDirty.value) return true
void saveBeforeClose()
return false
}
async function saveBeforeClose() {
const saved = await saveAndWait()
if (!saved) return
allowClose = true
modal.value?.hide()
}
async function openSource(location: RouteLocationRaw) {
if (isDirty.value || saving.value) return
if (appSettingsModal && !appSettingsModal.close()) return
allowClose = true
modal.value?.hide()
if (instanceSettings?.closeModal) {
instanceSettings.closeModal(() => void router.push(location))
} else {
await router.push(location)
}
}
function toggleVisibleSync() {
const enabled = !allCategorySettingsSynced.value
const candidates = enabled ? enableCandidates.value : disableCandidates.value
setSyncEnabled(
candidates.map((setting) => setting.option_id),
enabled,
)
}
defineExpose({ show, hide })
</script>
<template>
<TabbedModal
ref="modal"
:tabs="categoryTabs"
:header="modalTitle"
:before-hide="beforeHide"
:before-tab-change="changeCategory"
:hide-tab-selection="search.trim().length > 0"
:on-after-hide="reset"
:floating-action-bar-shown="isDirty"
max-width="min(1080px, calc(95vw - 2rem))"
width="min(1080px, calc(95vw - 2rem))"
>
<template #sidebar-header>
<div class="pb-4">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="search"
autocomplete="off"
:placeholder="formatMessage(messages.search)"
:aria-label="formatMessage(messages.search)"
wrapper-class="w-full shrink-0"
/>
</div>
</template>
<template #content>
<div class="flex min-h-full min-w-0 flex-col">
<div
v-if="!isLocalEditor && (loading || loadError || categorySettings.length > 0)"
class="mb-4 flex shrink-0 flex-wrap items-start justify-between gap-4 border-0 border-b border-solid border-surface-4 pb-4"
>
<p class="m-0 min-w-60 flex-1 text-[var(--color-text-default)]">
{{ formatMessage(messages.syncedOptionsDescription) }}
</p>
<div class="ml-auto flex w-48 justify-end">
<Button
size="lg"
:disabled="
allCategorySettingsSynced
? disableCandidates.length === 0
: enableCandidates.length === 0
"
@click="toggleVisibleSync"
>
<LinkIcon v-if="allCategorySettingsSynced" aria-hidden="true" />
<UnlinkIcon v-else aria-hidden="true" />
{{
formatMessage(allCategorySettingsSynced ? messages.disableAll : messages.enableAll)
}}
</Button>
</div>
</div>
<div
v-if="loading"
role="status"
:aria-label="formatMessage(messages.loading)"
class="flex flex-1 flex-col gap-4"
>
<div
v-for="row in 5"
:key="row"
class="h-20 animate-pulse rounded-xl bg-surface-3"
aria-hidden="true"
/>
</div>
<div
v-else-if="loadError"
class="flex min-h-40 flex-1 flex-col items-center justify-center gap-3 text-[var(--color-text-tertiary)]"
>
<p class="m-0">{{ formatMessage(messages.loadFailed) }}</p>
<Button @click="load">
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.retry) }}
</Button>
</div>
<div v-else class="min-h-0 flex-1">
<div
v-if="categorySettings.length === 0"
class="flex h-full min-h-40 items-center justify-center px-8 text-center text-[var(--color-text-tertiary)]"
>
{{
activeCategory?.is_custom && !search
? formatMessage(messages.customEmpty)
: formatMessage(messages.empty)
}}
</div>
<div v-else class="flex flex-col gap-4">
<GameSettingRow
v-for="setting in categorySettings"
:key="setting.option_id"
:setting="setting"
:locale-label="localeLabels[setting.option_id]"
:keybind-conflicts="keybindConflicts.get(setting.option_id)"
:show-sync-toggle="!isLocalEditor"
:source-navigation-disabled="isDirty || saving"
@open-source="openSource"
@update:sync-enabled="setSyncEnabled([setting.option_id], $event)"
@update:canonical-value="setCanonicalValue(setting.option_id, $event)"
/>
</div>
</div>
</div>
</template>
<template #floating-action-bar>
<FloatingActionBar v-if="isDirty" shown inline :aria-label="modalTitle">
<p class="m-0 text-sm font-semibold md:text-base">
{{ formatMessage(messages.unsavedChanges) }}
</p>
<div class="ml-auto flex gap-2">
<Button type="outlined" @click="cancelChanges">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!isDirty || loading || saving || hasBlockingDraft"
:loading="saving"
@click="save"
>
<SaveIcon aria-hidden="true" />
{{ formatMessage(messages.saveSettings) }}
</Button>
</div>
</FloatingActionBar>
</template>
</TabbedModal>
</template>
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { TriangleAlertIcon } from '@modrinth/assets'
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onBeforeUnmount, ref, useId, watch } from 'vue'
import {
activateKeybindRecording,
deactivateKeybindRecording,
formatMinecraftKeybind,
minecraftKeyTokenFromKeyboardEvent,
minecraftMouseTokenFromButton,
} from './keybinds'
const props = withDefaults(
defineProps<{
modelValue: string
settingLabel: string
conflicts?: string[]
mixed?: boolean
disabled?: boolean
}>(),
{
conflicts: () => [],
mixed: false,
disabled: false,
},
)
const emit = defineEmits<{
'update:model-value': [value: string]
}>()
const { formatMessage, locale } = useVIntl()
const statusId = useId()
const conflictId = useId()
const recording = ref(false)
const statusMessage = ref('')
const controlElement = ref<HTMLElement | null>(null)
let suppressNextClick = false
const messages = defineMessages({
listen: {
id: 'app.settings.game-options.keybind.listen',
defaultMessage: 'Press a key…',
},
choose: {
id: 'app.settings.game-options.keybind.choose',
defaultMessage: 'Choose a key',
},
mixed: {
id: 'app.settings.game-options.keybind.mixed',
defaultMessage: 'Different across instances',
},
change: {
id: 'app.settings.game-options.keybind.change',
defaultMessage: '{setting}: {binding}. Activate to change the binding.',
},
listeningStatus: {
id: 'app.settings.game-options.keybind.listening-status',
defaultMessage: 'Listening for a key or mouse button. Press Escape to clear this binding.',
},
recordingLabel: {
id: 'app.settings.game-options.keybind.recording-label',
defaultMessage:
'{setting}: listening for a key or mouse button. Press Escape to clear this binding.',
},
unsupportedStatus: {
id: 'app.settings.game-options.keybind.unsupported-status',
defaultMessage: 'That input cannot be used by Minecraft. Press another key.',
},
cancelledStatus: {
id: 'app.settings.game-options.keybind.cancelled-status',
defaultMessage: 'Key binding change cancelled.',
},
assignedStatus: {
id: 'app.settings.game-options.keybind.assigned-status',
defaultMessage: '{setting} is now bound to {binding}.',
},
conflict: {
id: 'app.settings.game-options.keybind.conflict-description',
defaultMessage: 'This is already used by {settings}.',
},
})
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
const bindingLabel = computed(() => {
if (props.mixed) return formatMessage(messages.mixed)
if (!props.modelValue) return formatMessage(messages.choose)
return formatMinecraftKeybind(formatMessage, props.modelValue, isMac)
})
const visibleLabel = computed(() =>
recording.value ? formatMessage(messages.listen) : bindingLabel.value,
)
const conflictSettings = computed(() =>
new Intl.ListFormat(locale.value, { style: 'long', type: 'conjunction' }).format(
props.conflicts.map((setting) => `‘${setting}’`),
),
)
const conflictMessage = computed(() =>
props.conflicts.length
? formatMessage(messages.conflict, { settings: conflictSettings.value })
: '',
)
const accessibleLabel = computed(() =>
formatMessage(recording.value ? messages.recordingLabel : messages.change, {
setting: props.settingLabel,
binding: bindingLabel.value,
}),
)
function startRecording() {
if (props.disabled || recording.value) return
activateKeybindRecording(cancelRecording)
recording.value = true
statusMessage.value = formatMessage(messages.listeningStatus)
window.addEventListener('keydown', handleKeydown, true)
window.addEventListener('pointerdown', handlePointerDown, true)
window.addEventListener('blur', cancelRecording)
}
function stopRecording() {
recording.value = false
window.removeEventListener('keydown', handleKeydown, true)
window.removeEventListener('pointerdown', handlePointerDown, true)
window.removeEventListener('blur', cancelRecording)
deactivateKeybindRecording(cancelRecording)
}
function cancelRecording() {
if (!recording.value) return
stopRecording()
statusMessage.value = formatMessage(messages.cancelledStatus)
}
function assign(value: string) {
if (!recording.value) return
stopRecording()
emit('update:model-value', value)
statusMessage.value = formatMessage(messages.assignedStatus, {
setting: props.settingLabel,
binding: formatMinecraftKeybind(formatMessage, value, isMac),
})
}
function handleClick(event: MouseEvent) {
if (suppressNextClick) {
suppressNextClick = false
event.preventDefault()
event.stopPropagation()
return
}
startRecording()
}
function handleKeydown(event: KeyboardEvent) {
if (!recording.value) return
event.preventDefault()
event.stopPropagation()
if (event.code === 'Escape') {
assign('key.keyboard.unknown')
return
}
if (event.repeat) return
const token = event.isComposing ? null : minecraftKeyTokenFromKeyboardEvent(event)
if (!token) {
statusMessage.value = formatMessage(messages.unsupportedStatus)
return
}
assign(token)
}
function handlePointerDown(event: PointerEvent) {
if (!recording.value) return
if (event.pointerType !== 'mouse') return
const otherKeybindControl =
event.target instanceof Element
? event.target.closest<HTMLElement>('[data-game-keybind-control]')
: null
if (otherKeybindControl && otherKeybindControl !== controlElement.value) return
event.preventDefault()
event.stopPropagation()
const token = minecraftMouseTokenFromButton(event.button)
if (!token) {
statusMessage.value = formatMessage(messages.unsupportedStatus)
return
}
suppressNextClick = event.button === 0 && otherKeybindControl === controlElement.value
assign(token)
}
watch(
() => props.disabled,
(disabled) => {
if (disabled) cancelRecording()
},
)
onBeforeUnmount(cancelRecording)
</script>
<template>
<div class="flex w-full min-w-0 items-center gap-1.5">
<span
ref="controlElement"
v-tooltip="!recording && conflictMessage ? conflictMessage : undefined"
data-game-keybind-control
class="flex min-w-0 flex-1"
>
<Button
:type="recording || conflicts.length ? 'outlined' : 'base'"
:color="recording ? 'brand' : conflicts.length ? 'orange' : undefined"
size="md"
:disabled="disabled"
:aria-label="accessibleLabel"
:aria-describedby="recording ? statusId : conflicts.length ? conflictId : undefined"
class="w-full overflow-hidden"
@click="handleClick"
@contextmenu.prevent.stop
@auxclick.prevent.stop
>
<TriangleAlertIcon v-if="!recording && conflicts.length" aria-hidden="true" />
<span class="min-w-0 truncate">{{ visibleLabel }}</span>
</Button>
</span>
<span :id="statusId" class="visually-hidden" role="status" aria-live="polite">
{{ statusMessage }}
</span>
<span v-if="conflicts.length" :id="conflictId" class="visually-hidden">
{{ conflictMessage }}
</span>
</div>
</template>
@@ -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<string, string> = {
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<string, MessageDescriptor | string> = {
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}`
}
@@ -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<string, string> = {
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<string>[] = Object.entries(languageNames)
.map(([value, label]) => ({ value, label, searchTerms: [value] }))
.sort((a, b) => a.label.localeCompare(b.label))
@@ -0,0 +1,384 @@
<script setup lang="ts">
import { LinkIcon, UnknownIcon, UnlinkIcon } from '@modrinth/assets'
import {
Avatar,
Combobox,
type ComboboxOption,
defineMessages,
IconButton,
Slider,
StyledInput,
truncatedTooltip,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { type RouteLocationRaw, RouterLink } from 'vue-router'
import type {
EditableGameSetting,
GameOptionCanonicalValue,
GameSettingLocaleLabel,
} from '@/helpers/game-options'
import GameSettingBooleanControl from './boolean-control.vue'
import {
canonicalBooleanValue,
canonicalValueFromInput,
canonicalValueText,
gameSettingNumberScale,
isKeybindSetting,
settingCanBeEnabled,
} from './editors'
import GameKeybindInput from './keybind-input.vue'
import { minecraftLanguageOptions } from './languages'
import {
formatGameSettingChoice,
formatGameSettingDescription,
formatGameSettingLabel,
formatGameSettingValidation,
presentationMessages,
} from './messages'
const props = withDefaults(
defineProps<{
setting: EditableGameSetting
localeLabel?: GameSettingLocaleLabel
keybindConflicts?: string[]
disabled?: boolean
showSyncToggle?: boolean
sourceNavigationDisabled?: boolean
}>(),
{
keybindConflicts: () => [],
disabled: false,
showSyncToggle: true,
sourceNavigationDisabled: false,
},
)
const emit = defineEmits<{
'update:sync-enabled': [enabled: boolean]
'update:canonical-value': [value: GameOptionCanonicalValue | null]
'open-source': [location: RouteLocationRaw]
}>()
const { formatMessage } = useVIntl()
const settingLabelRef = ref<HTMLElement | null>(null)
const messages = defineMessages({
on: {
id: 'app.settings.synced-options.game-settings.value-on',
defaultMessage: 'On',
},
off: {
id: 'app.settings.synced-options.game-settings.value-off',
defaultMessage: 'Off',
},
mixed: {
id: 'app.settings.synced-options.game-settings.value-mixed',
defaultMessage: 'Different across instances',
},
unset: {
id: 'app.settings.synced-options.game-settings.value-unset',
defaultMessage: 'Choose a value',
},
syncSetting: {
id: 'app.settings.synced-options.game-settings.sync-setting',
defaultMessage: 'Sync {setting} across instances',
},
unsyncSetting: {
id: 'app.settings.synced-options.game-settings.unsync-setting',
defaultMessage: 'Stop syncing {setting} across instances',
},
syncedAcrossInstances: {
id: 'app.settings.synced-options.game-settings.synced-across-instances',
defaultMessage: 'Synced across instances',
},
notBeingSynced: {
id: 'app.settings.synced-options.game-settings.not-being-synced',
defaultMessage: 'Not being synced',
},
})
const settingLabel = computed(() => {
if (props.setting.kind === 'external') {
return props.localeLabel?.label ?? formatGameSettingLabel(formatMessage, props.setting)
}
return formatGameSettingLabel(formatMessage, props.setting)
})
const settingDescription = computed(() =>
formatGameSettingDescription(formatMessage, props.setting),
)
const settingSource = computed(() =>
props.setting.kind === 'external' ? props.localeLabel?.source : undefined,
)
const sourceLocation = computed<RouteLocationRaw>(() => {
const source = settingSource.value
if (source?.project) {
return { path: `/project/${source.project.id}` }
}
return {
name: 'InstanceContent',
params: { id: source?.instance_id },
query: { highlight: source?.file_path },
}
})
const valueText = computed(() => canonicalValueText(props.setting))
const languageOptions = computed<ComboboxOption<string>[]>(() => {
const value = valueText.value
if (value && !minecraftLanguageOptions.some((option) => option.value === value)) {
return [{ value, label: value }, ...minecraftLanguageOptions]
}
return minecraftLanguageOptions
})
const enumOptions = computed<ComboboxOption<string>[]>(() =>
(props.setting.editor.choices ?? []).map((choice) => ({
value: choice.value,
label:
props.setting.kind === 'external'
? (props.localeLabel?.choices[choice.value] ?? choice.value)
: formatGameSettingChoice(formatMessage, props.setting.option_id, choice.value),
})),
)
const isNumber = computed(
() => props.setting.editor.type === 'integer' || props.setting.editor.type === 'decimal',
)
const isSlider = computed(
() => isNumber.value && props.setting.editor.min != null && props.setting.editor.max != null,
)
const booleanValue = computed(() => canonicalBooleanValue(props.setting))
const numberScale = computed(() => gameSettingNumberScale(props.setting))
const inputMin = computed(() =>
props.setting.editor.min === null || props.setting.editor.min === undefined
? undefined
: props.setting.editor.min * numberScale.value,
)
const inputMax = computed(() =>
props.setting.editor.max === null || props.setting.editor.max === undefined
? undefined
: props.setting.editor.max * numberScale.value,
)
const inputStep = computed(() =>
props.setting.editor.step === null || props.setting.editor.step === undefined
? undefined
: props.setting.editor.step * numberScale.value,
)
const sliderValue = computed(() => {
if (valueText.value === '') return null
const value = Number(valueText.value)
return Number.isFinite(value) ? value : null
})
const inputValue = computed(() =>
isNumber.value ? (sliderValue.value ?? undefined) : valueText.value,
)
const editorDisabled = computed(
() =>
props.disabled ||
props.setting.controlled ||
(props.showSyncToggle && !props.setting.sync_enabled),
)
const syncToggleDisabled = computed(
() =>
props.disabled ||
props.setting.controlled ||
(!props.setting.sync_enabled && !settingCanBeEnabled(props.setting)),
)
const placeholder = computed(() => {
if (props.setting.value_state === 'mixed') return formatMessage(messages.mixed)
if (props.setting.value_state === 'unset') return formatMessage(messages.unset)
if (props.setting.editor.type === 'external_raw') {
return formatMessage(presentationMessages.customValuePlaceholder)
}
return undefined
})
const validationMessage = computed(() =>
formatGameSettingValidation(formatMessage, props.setting.validation_error),
)
const syncDisabledReason = computed(() => {
if (validationMessage.value) return validationMessage.value
if (props.setting.value_state === 'invalid') {
return formatMessage(presentationMessages.validationInvalidValue)
}
if (props.setting.value_state === 'mixed' || props.setting.value_state === 'unset') {
return formatMessage(presentationMessages.validationMissingValue)
}
if (props.setting.controlled) {
return formatMessage(presentationMessages.bucketLauncherControlled, { count: 1 })
}
if (
props.setting.compatibility.total_participating > 0 &&
props.setting.compatibility.will_receive === 0
) {
return formatMessage(presentationMessages.compatibilityNone)
}
return undefined
})
const syncActionLabel = computed(() =>
formatMessage(props.setting.sync_enabled ? messages.unsyncSetting : messages.syncSetting, {
setting: settingLabel.value,
}),
)
const syncStatusTooltip = computed(() =>
formatMessage(
props.setting.sync_enabled ? messages.syncedAcrossInstances : messages.notBeingSynced,
),
)
function updateValue(value: string | number | boolean | undefined) {
emit('update:canonical-value', canonicalValueFromInput(props.setting, value))
}
</script>
<template>
<div
class="grid min-h-[54px] min-w-0 items-center gap-2"
:class="
isSlider
? showSyncToggle
? 'grid-cols-[minmax(10rem,0.65fr)_minmax(0,1.35fr)_2.25rem]'
: 'grid-cols-[minmax(10rem,0.65fr)_minmax(0,1.35fr)]'
: showSyncToggle
? 'grid-cols-[minmax(0,1fr)_12rem_2.25rem]'
: 'grid-cols-[minmax(0,1fr)_12rem]'
"
>
<div class="min-w-0 pr-2">
<div class="flex items-center gap-2">
<h3
ref="settingLabelRef"
v-tooltip="truncatedTooltip(settingLabelRef, settingLabel)"
class="m-0 truncate text-lg font-semibold text-[var(--color-text-primary)]"
>
{{ settingLabel }}
</h3>
<span
v-if="validationMessage"
v-tooltip="validationMessage"
tabindex="0"
role="img"
class="flex shrink-0 rounded-md text-orange outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
>
<UnknownIcon class="size-4" aria-hidden="true" />
</span>
</div>
<RouterLink v-if="settingSource" v-slot="{ href }" :to="sourceLocation" custom>
<a
:href="sourceNavigationDisabled ? undefined : href"
:aria-disabled="sourceNavigationDisabled || undefined"
class="mt-2 inline-flex max-w-full items-center gap-1.5 text-[var(--color-text-default)]"
:class="sourceNavigationDisabled ? 'cursor-default' : 'hover:underline'"
@click.prevent="!sourceNavigationDisabled && emit('open-source', sourceLocation)"
>
<Avatar
v-if="settingSource.project"
:src="settingSource.project.icon_url"
size="1.25rem"
no-shadow
/>
<span class="truncate">{{
settingSource.project?.title ?? settingSource.file_name
}}</span>
</a>
</RouterLink>
<p v-else-if="settingDescription" class="m-0 mt-0.5 text-[var(--color-text-default)]">
{{ settingDescription }}
</p>
</div>
<div class="flex min-w-0 items-center justify-end">
<Slider
v-if="isSlider"
size="standard"
:model-value="sliderValue"
:min="inputMin ?? 0"
:max="inputMax ?? 100"
:step="inputStep ?? 1"
:snap-points="setting.category_id === 'music_and_sound' ? [0, 50, 100] : []"
:snap-range="5"
:unit="setting.editor.unit === 'percent' ? '%' : undefined"
:placeholder="placeholder"
:aria-label="settingLabel"
:disabled="editorDisabled"
@update:model-value="updateValue"
/>
<GameSettingBooleanControl
v-else-if="setting.editor.type === 'bool'"
:model-value="booleanValue"
:label="settingLabel"
:on-label="formatMessage(messages.on)"
:off-label="formatMessage(messages.off)"
:placeholder="placeholder"
:disabled="editorDisabled"
@update:model-value="updateValue"
/>
<Combobox
v-else-if="setting.option_id === 'language'"
:model-value="valueText"
:options="languageOptions"
:placeholder="placeholder"
:disabled="editorDisabled"
:aria-label="settingLabel"
:search-input-attrs="{ 'aria-label': settingLabel }"
searchable
select-search-text-on-focus
class="min-w-0"
@update:model-value="updateValue"
/>
<Combobox
v-else-if="setting.editor.type === 'enum'"
:model-value="valueText"
:options="enumOptions"
:placeholder="placeholder"
:disabled="editorDisabled"
:aria-label="settingLabel"
class="min-w-0"
@update:model-value="updateValue"
/>
<GameKeybindInput
v-else-if="isKeybindSetting(setting)"
:model-value="valueText"
:setting-label="settingLabel"
:conflicts="keybindConflicts"
:mixed="setting.value_state === 'mixed'"
:disabled="editorDisabled"
@update:model-value="updateValue"
/>
<StyledInput
v-else
:model-value="inputValue"
:type="isNumber ? 'number' : 'text'"
:min="inputMin"
:max="inputMax"
:step="inputStep"
:placeholder="placeholder"
:disabled="editorDisabled"
:aria-label="settingLabel"
wrapper-class="w-full"
@update:model-value="updateValue"
/>
</div>
<span
v-if="showSyncToggle"
v-tooltip="syncToggleDisabled && syncDisabledReason ? syncDisabledReason : syncStatusTooltip"
class="flex justify-center"
>
<IconButton
:type="setting.sync_enabled ? 'chip' : 'base'"
:color="setting.sync_enabled ? 'blue' : undefined"
:label="syncActionLabel"
:disabled="syncToggleDisabled"
:aria-pressed="setting.sync_enabled"
@click="emit('update:sync-enabled', !setting.sync_enabled)"
>
<LinkIcon v-if="setting.sync_enabled" aria-hidden="true" />
<UnlinkIcon v-else aria-hidden="true" />
</IconButton>
</span>
</div>
</template>
@@ -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<string | undefined>,
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<GameSettingsEditorState | null>(null)
const draftState = ref<GameSettingsEditorState | null>(null)
const touchedValueOptionIds = ref<Set<string>>(new Set())
let previewTimer: ReturnType<typeof setTimeout> | null = null
let previewGeneration = 0
let loadGeneration = 0
let saveGeneration = 0
const editorInstanceId = ref<string>()
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<boolean> {
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<string>,
conflictOptionIds: ReadonlySet<string> = 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,
}
}
@@ -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<boolean>,
instanceId: MaybeRefOrGetter<string | undefined>,
settings: MaybeRefOrGetter<EditableGameSetting[]>,
) {
const debug = useDebugLogger('GameSettingLabels')
const { locale } = useVIntl()
const labels = shallowRef<Record<string, GameSettingLocaleLabel>>({})
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
}
@@ -0,0 +1,476 @@
<script setup lang="ts">
import {
ClipboardCopyIcon,
EditIcon,
MoreVerticalIcon,
NoSignalIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
SignalIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
type ButtonMenuOption,
commonMessages,
defineMessages,
FilterPills,
injectNotificationManager,
NewModal,
StyledInput,
Table,
type TableColumn,
TeleportOverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { remove_synced_server, type SyncedServer, update_synced_server } from '@/helpers/instance'
import { syncedOptionsKeys, syncedServersQueryOptions } from '@/helpers/synced-options'
import { copyToClipboard } from '@/helpers/utils'
import {
refreshServerData,
refreshServers,
type ServerData,
type ServerWorld,
} from '@/helpers/worlds'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const serverEditorModal = ref<InstanceType<typeof NewModal> | null>(null)
const editServerModal = ref<InstanceType<typeof NewModal> | null>(null)
const editedServer = ref<SyncedServer | null>(null)
const serversQuery = useQuery(syncedServersQueryOptions())
const syncedServers = computed(() => serversQuery.data.value ?? [])
const serverData = ref<Record<string, ServerData>>({})
const serverSearch = ref('')
const selectedServerFilters = ref<string[]>([])
const messages = defineMessages({
serverEditorTitle: {
id: 'app.settings.synced-options.multiplayer-servers.editor-title',
defaultMessage: 'Edit synced servers',
},
editServerTitle: {
id: 'instance.edit-server.title',
defaultMessage: 'Edit server',
},
serverName: {
id: 'app.settings.synced-options.multiplayer-servers.name',
defaultMessage: 'Server name',
},
serverAddress: {
id: 'app.settings.synced-options.multiplayer-servers.address',
defaultMessage: 'Server address',
},
searchServers: {
id: 'app.settings.synced-options.multiplayer-servers.search',
defaultMessage:
'Search {count, number} {count, plural, one {synced server} other {synced servers}}...',
},
serverStatus: {
id: 'app.settings.synced-options.multiplayer-servers.status',
defaultMessage: 'Status',
},
onlineFilter: {
id: 'app.settings.synced-options.multiplayer-servers.filter-online',
defaultMessage: 'Online',
},
offlineFilter: {
id: 'app.settings.synced-options.multiplayer-servers.filter-offline',
defaultMessage: 'Offline',
},
playersOnline: {
id: 'app.settings.synced-options.multiplayer-servers.players-online',
defaultMessage: '{count, number} online',
},
noMatchingServers: {
id: 'app.settings.synced-options.multiplayer-servers.no-results',
defaultMessage: 'No synced servers match your search or filters.',
},
serverCount: {
id: 'app.settings.synced-options.multiplayer-servers.count',
defaultMessage: '{count, plural, one {# server} other {# servers}}',
},
moreServerOptions: {
id: 'app.settings.synced-options.multiplayer-servers.more-options',
defaultMessage: 'More server options',
},
copyServerAddress: {
id: 'app.settings.synced-options.multiplayer-servers.copy-address',
defaultMessage: 'Copy address',
},
noSyncedServers: {
id: 'app.settings.synced-options.multiplayer-servers.empty',
defaultMessage: 'No user-added servers are currently synced.',
},
})
const serverWorlds = computed<ServerWorld[]>(() =>
syncedServers.value.map((server, index) => ({
name: server.name,
type: 'server',
index,
server_id: server.id,
address: server.address,
pack_status:
server.accept_textures === true
? 'enabled'
: server.accept_textures === false
? 'disabled'
: 'prompt',
display_status: 'normal',
})),
)
type SyncedServerTableColumn = 'server' | 'status' | 'version' | 'actions'
type SyncedServerTableRow = SyncedServer & {
server: string
status: 'online' | 'offline'
version: string
actions: null
}
const syncedServerColumns = computed<TableColumn<SyncedServerTableColumn>[]>(() => [
{
key: 'server',
label: formatMessage(commonMessages.serverLabel),
width: '45%',
cellClass: '!h-16',
},
{
key: 'status',
label: formatMessage(messages.serverStatus),
width: '25%',
cellClass: '!h-16',
},
{
key: 'version',
label: formatMessage(commonMessages.versionLabel),
width: '22%',
cellClass: '!h-16',
},
{
key: 'actions',
label: formatMessage(commonMessages.actionsLabel),
align: 'right',
width: '6rem',
cellClass: '!h-16',
},
])
const syncedServerFilterOptions = computed(() => [
{ id: 'online', label: formatMessage(messages.onlineFilter) },
{ id: 'offline', label: formatMessage(messages.offlineFilter) },
])
const syncedServerRows = computed<SyncedServerTableRow[]>(() =>
syncedServers.value.map((server) => ({
...server,
server: server.name,
status: serverData.value[server.address]?.status ? 'online' : 'offline',
version: serverData.value[server.address]?.status?.version?.name ?? '',
actions: null,
})),
)
const statusFilteredSyncedServerRows = computed(() => {
if (selectedServerFilters.value.length === 0) return syncedServerRows.value
return syncedServerRows.value.filter((server) =>
selectedServerFilters.value.includes(server.status),
)
})
const filteredSyncedServerRows = computed(() => {
const search = serverSearch.value.trim().toLocaleLowerCase()
if (!search) return statusFilteredSyncedServerRows.value
return statusFilteredSyncedServerRows.value.filter(
(server) =>
server.name.toLocaleLowerCase().includes(search) ||
server.address.toLocaleLowerCase().includes(search),
)
})
const refreshMutation = useMutation({
mutationFn: () => refreshServers(serverWorlds.value, serverData.value, null),
onError: handleError,
})
const refreshingAllSyncedServers = refreshMutation.isPending
const saveMutation = useMutation({
mutationFn: update_synced_server,
onSuccess: async (_, server) => {
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
servers?.map((current) => (current.id === server.id ? { ...server } : current)),
)
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
void refreshSyncedServer(server.address).catch(handleError)
},
onError: handleError,
})
const removeMutation = useMutation({
mutationFn: remove_synced_server,
onSuccess: async (_, serverId) => {
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
servers?.filter((server) => server.id !== serverId),
)
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
},
onError: handleError,
})
async function show() {
const result = await serversQuery.refetch()
if (result.isError) {
handleError(result.error)
return
}
serverSearch.value = ''
selectedServerFilters.value = []
serverData.value = {}
serverEditorModal.value?.show()
refreshAllSyncedServers()
}
function openSyncedServerEditor(server: SyncedServer) {
editedServer.value = { ...server }
editServerModal.value?.show()
}
async function saveSyncedServer() {
if (!editedServer.value || saveMutation.isPending.value) return
try {
await saveMutation.mutateAsync({ ...editedServer.value })
await nextTick()
editServerModal.value?.hide()
} catch {
return
}
}
async function refreshSyncedServer(address: string) {
serverData.value[address] ??= { refreshing: true }
await refreshServerData(serverData.value[address], null, address)
}
function refreshAllSyncedServers() {
if (!refreshingAllSyncedServers.value) refreshMutation.mutate()
}
function syncedServerMenuOptions(server: SyncedServer): ButtonMenuOption[] {
return [
{
id: 'refresh',
label: formatMessage(commonMessages.refreshButton),
icon: RefreshCwIcon,
action: () => refreshSyncedServer(server.address).catch(handleError),
},
{
id: 'copy-address',
label: formatMessage(messages.copyServerAddress),
icon: ClipboardCopyIcon,
action: () => copyToClipboard(server.address),
},
{
id: 'edit',
label: formatMessage(commonMessages.editButton),
icon: EditIcon,
action: () => openSyncedServerEditor(server),
},
{ type: 'divider' },
{
id: 'remove',
label: formatMessage(commonMessages.removeButton),
icon: TrashIcon,
tone: 'red',
disabled: removeMutation.isPending.value,
action: () => removeMutation.mutate(server.id),
},
]
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="serverEditorModal"
:header="formatMessage(messages.serverEditorTitle)"
no-padding
max-width="min(928px, calc(100vw - 2rem))"
width="min(928px, calc(100vw - 2rem))"
>
<div class="flex max-h-[min(600px,calc(95vh-10rem))] flex-col">
<div
class="flex shrink-0 flex-col gap-4 border-0 border-b border-solid border-surface-4 px-6 py-4"
>
<StyledInput
v-model="serverSearch"
:icon="SearchIcon"
type="search"
autocomplete="off"
:spellcheck="false"
:placeholder="formatMessage(messages.searchServers, { count: syncedServers.length })"
size="medium"
:aria-label="formatMessage(messages.searchServers, { count: syncedServers.length })"
:clear-label="formatMessage(commonMessages.clearButton)"
clearable
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<FilterPills v-model="selectedServerFilters" :options="syncedServerFilterOptions">
<template #all>
{{ formatMessage(commonMessages.allProjectType) }}
</template>
</FilterPills>
<Button
type="quiet"
:disabled="refreshingAllSyncedServers || syncedServers.length === 0"
interaction="none"
@click="refreshAllSyncedServers"
>
<RefreshCwIcon
:class="{ 'animate-spin': refreshingAllSyncedServers }"
aria-hidden="true"
/>
{{ formatMessage(commonMessages.refreshButton) }}
</Button>
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
v-if="syncedServers.length === 0"
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.noSyncedServers) }}
</div>
<div
v-else-if="filteredSyncedServerRows.length === 0"
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.noMatchingServers) }}
</div>
<div v-else class="min-h-0 flex-1 overflow-y-auto">
<Table
:columns="syncedServerColumns"
:data="filteredSyncedServerRows"
row-key="id"
table-min-width="44rem"
class="!rounded-none !border-0"
>
<template #cell-server="{ row }">
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="serverData[row.address]?.status?.favicon"
:alt="row.name"
:tint-by="row.address"
size="32px"
no-shadow
class="shrink-0 !rounded-lg"
/>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
row.name
}}</span>
<span class="truncate text-sm text-[var(--color-text-tertiary)]">{{
row.address
}}</span>
</div>
</div>
</template>
<template #cell-status="{ row }">
<span class="inline-flex items-center gap-1.5 font-medium">
<RefreshCwIcon
v-if="serverData[row.address]?.refreshing"
class="size-4 shrink-0 animate-spin"
aria-hidden="true"
/>
<SignalIcon
v-else-if="serverData[row.address]?.status"
class="size-4 shrink-0 text-green"
aria-hidden="true"
/>
<NoSignalIcon v-else class="size-4 shrink-0" aria-hidden="true" />
<template v-if="serverData[row.address]?.refreshing">
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<template v-else-if="serverData[row.address]?.status">
{{
formatMessage(messages.playersOnline, {
count: serverData[row.address]?.status?.players?.online ?? 0,
})
}}
</template>
<template v-else>{{ formatMessage(messages.offlineFilter) }}</template>
</span>
</template>
<template #cell-version="{ row }">
<span class="block truncate">{{ row.version || '—' }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<TeleportOverflowMenu
type="quiet"
:label="formatMessage(messages.moreServerOptions)"
:options="syncedServerMenuOptions(row)"
>
<MoreVerticalIcon aria-hidden="true" />
</TeleportOverflowMenu>
</div>
</template>
</Table>
</div>
</div>
<div
class="flex shrink-0 items-center border-0 border-t border-solid border-surface-4 px-6 py-4"
>
<span class="font-medium text-[var(--color-text-default)]">
{{ formatMessage(messages.serverCount, { count: syncedServers.length }) }}
</span>
</div>
</div>
</NewModal>
<NewModal
ref="editServerModal"
:header="formatMessage(messages.editServerTitle)"
:disable-close="saveMutation.isPending.value"
max-width="500px"
width="500px"
>
<div v-if="editedServer" class="flex flex-col gap-4">
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.serverName) }}
<StyledInput v-model="editedServer.name" autocomplete="off" wrapper-class="w-full" />
</label>
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.serverAddress) }}
<StyledInput v-model="editedServer.address" autocomplete="off" wrapper-class="w-full" />
</label>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="editServerModal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!editedServer?.address"
:loading="saveMutation.isPending.value"
@click="saveSyncedServer"
>
<SaveIcon aria-hidden="true" />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { CheckIcon, RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import {
type ArmorPreviewConfig,
ArmorTrimPanel,
Button,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted } from 'vue'
const messages = defineMessages({
title: { id: 'app.skins.armor.title', defaultMessage: 'Armour and trims' },
description: {
id: 'app.skins.armor.description',
defaultMessage: 'Try armour and trims on your skin. Nothing is stored until you save it.',
},
unsaved: { id: 'app.skins.armor.unsaved', defaultMessage: 'Unsaved changes' },
resetToSaved: { id: 'app.skins.armor.reset-to-saved', defaultMessage: 'Reset to saved' },
restoreDefaults: {
id: 'app.skins.armor.restore-defaults',
defaultMessage: 'Restore defaults',
},
save: { id: 'app.skins.armor.save', defaultMessage: 'Save' },
close: { id: 'app.skins.armor.close', defaultMessage: 'Close' },
})
const draft = defineModel<ArmorPreviewConfig>({ required: true })
const props = defineProps<{
saved: ArmorPreviewConfig
/** Stable id shared with the toggle so it can point `aria-controls` here. */
panelId: string
}>()
const emit = defineEmits<{
save: []
reset: []
defaults: []
close: []
}>()
const { formatMessage } = useVIntl()
/**
* The tab is mounted only while it is showing, so this listens exactly for that
* window. It listens in the capture phase because the app's own global
* shortcuts listen on `window` the same way, and a bubble-phase listener here
* would be skipped as soon as anything closer stopped the event. A modal on top
* of the page owns Escape while it is up, and focus sits inside it, so the key
* is left to it.
*/
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
if (
event.target instanceof Element &&
event.target.closest('[role="dialog"], [role="alertdialog"], [aria-modal="true"]')
) {
return
}
emit('close')
}
onMounted(() => window.addEventListener('keydown', handleKeydown, true))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown, true))
const isDirty = computed(() =>
Object.entries(draft.value).some(([slot, piece]) => {
const applied = props.saved[slot as keyof ArmorPreviewConfig]
return (
!applied ||
applied.material !== piece.material ||
applied.trimPattern !== piece.trimPattern ||
applied.trimMaterial !== piece.trimMaterial
)
}),
)
</script>
<template>
<div
:id="panelId"
role="region"
:aria-label="formatMessage(messages.title)"
class="flex min-h-[24rem] flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
>
<header class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="m-0 text-lg font-bold text-[var(--color-text-primary)]">
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 mt-1 text-sm leading-6 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.description) }}
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<span v-if="isDirty" class="text-sm font-semibold text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.unsaved) }}
</span>
<Button type="outlined" @click="emit('close')"
><XIcon />{{ formatMessage(messages.close) }}</Button
>
</div>
</header>
<ArmorTrimPanel v-model="draft" />
<footer
class="mt-auto flex flex-wrap items-center justify-between gap-2 border-0 border-t border-solid border-surface-5 pt-3"
>
<div class="flex flex-wrap items-center gap-2">
<Button type="outlined" :disabled="!isDirty" @click="emit('reset')"
><RotateCounterClockwiseIcon />{{ formatMessage(messages.resetToSaved) }}</Button
>
<Button type="outlined" @click="emit('defaults')">{{
formatMessage(messages.restoreDefaults)
}}</Button>
</div>
<Button type="colored" color="brand" :disabled="!isDirty" @click="emit('save')"
><CheckIcon />{{ formatMessage(messages.save) }}</Button
>
</footer>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
const { sections = 2, cards = 12 } = defineProps<{
sections?: number
cards?: number
}>()
</script>
<template>
<!--
Shaped after VirtualSkinSectionList: the same grid breakpoints and card
ratio, so the swap to real content does not move anything. Decorative, so
it stays out of the accessibility tree.
-->
<div class="flex flex-col gap-8" aria-hidden="true">
<div v-for="section in sections" :key="section" class="flex flex-col gap-4">
<div class="h-7 w-44 animate-pulse rounded-lg bg-surface-4" />
<div
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
>
<div
v-for="card in cards"
:key="card"
class="aspect-[31/40] w-full animate-pulse rounded-[20px] bg-surface-4"
/>
</div>
</div>
</div>
</template>
@@ -605,3 +605,110 @@ test('skin updates are restricted to known non-Mojang players and validated resu
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('hosted create auto-authenticates the first skin site player when no game account exists', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
let accounts: Array<{
account_type: string
yggdrasil?: { api_root: string }
}> = []
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request') {
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'site.jwt.secret',
},
} as MessageEvent,
frame,
)
return
}
if (data.type === 'starlight-skin-players-request') {
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: data.requestId,
ok: true,
players: [
{
uuid: '11111111-1111-1111-1111-111111111111',
name: 'AutoPlayer',
isMojang: false,
skinState: 'empty',
},
],
},
} as MessageEvent,
frame,
)
}
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
calls.push({ command, args })
if (command === 'plugin:install|hosted_has_game_account') {
return accounts.some(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
'https://skin.starlight.cool/yggdrasil'),
)
}
if (command === 'plugin:auth|login_skin_site_player') {
accounts = [
{
account_type: 'yggdrasil',
yggdrasil: { api_root: 'https://skin.starlight.cool/yggdrasil' },
},
]
return null
}
if (command === 'plugin:instance|instance_get') return null
return command === 'plugin:install|hosted_create' ? 'auto-instance' : null
},
},
},
})
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
},
} as MessageEvent,
frame,
)
await requestSkinSitePlayers().catch(() => {})
assert.equal(await hostedCreate(), 'auto-instance')
assert.ok(
calls.some(({ command }) => command === 'plugin:auth|login_skin_site_player'),
'expected automatic player authentication',
)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else delete (globalThis as { window?: unknown }).window
}
})
@@ -0,0 +1,61 @@
/**
* Starlight anniversary helpers.
*
* Starlight's first release was on 2014-10-04, so every October 4th the
* launcher shows a small celebration. The anniversary number is simply
* `currentYear - 2014`.
*/
/** Year of Starlight's first release. */
export const STARLIGHT_FOUNDING_YEAR = 2014
/** Month (1-12) of Starlight's anniversary. */
export const STARLIGHT_ANNIVERSARY_MONTH = 10
/** Day of month of Starlight's anniversary. */
export const STARLIGHT_ANNIVERSARY_DAY = 4
/**
* Whether the given date falls on Starlight's anniversary (October 4th).
* Uses the local timezone, which is what the launcher user experiences.
*/
export function isAnniversary(date: Date = new Date()): boolean {
return (
date.getMonth() + 1 === STARLIGHT_ANNIVERSARY_MONTH &&
date.getDate() === STARLIGHT_ANNIVERSARY_DAY
)
}
/**
* The anniversary number for the given date (e.g. 12 for 2026).
* Not clamped: callers decide whether to guard against pre-2014 dates.
*/
export function anniversaryYear(date: Date = new Date()): number {
return date.getFullYear() - STARLIGHT_FOUNDING_YEAR
}
/**
* Whether the celebration should currently be shown.
*
* Normally this is only true on October 4th, but the developer-page
* `force_anniversary_celebration` feature flag forces it on for previewing.
*/
export function shouldCelebrate(force = false, date: Date = new Date()): boolean {
return force || isAnniversary(date)
}
/** English ordinal suffix for a number (1 -> "1st", 12 -> "12th"). */
export function ordinal(n: number): string {
const mod100 = n % 100
if (mod100 >= 11 && mod100 <= 13) return `${n}th`
switch (n % 10) {
case 1:
return `${n}st`
case 2:
return `${n}nd`
case 3:
return `${n}rd`
default:
return `${n}th`
}
}
@@ -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<string, string>
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<string, GameSettingLocaleLabel> }> {
return await invoke('plugin:instance|instance_get_game_setting_locale_labels', {
instanceId,
locale,
optionIds,
refreshSources,
})
}
export async function list_game_options_sync_sources(): Promise<GameOptionsSourceCandidate[]> {
return await invoke('plugin:instance|instance_list_game_options_sync_sources')
}
export async function get_synced_game_options_config(): Promise<GameSettingsEditorState> {
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<GameSettingsEditorState> {
return await invoke('plugin:instance|instance_preview_synced_game_option_changes', { request })
}
export async function save_synced_game_option_changes(
request: UpdateGameSettingsRequest,
): Promise<SaveGameSettingsResult> {
return await invoke('plugin:instance|instance_save_synced_game_option_changes', { request })
}
export async function get_local_game_options_config(
instanceId: string,
): Promise<GameSettingsEditorState> {
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<GameSettingsEditorState> {
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<SaveGameSettingsResult> {
return await invoke('plugin:instance|instance_save_local_game_option_changes', {
instanceId,
request,
})
}
+32 -3
View File
@@ -2,10 +2,13 @@ import { invoke } from '@tauri-apps/api/core'
import {
requestSkinSiteDownloadToken,
requestSkinSitePlayers,
skinSitePlayers,
skinSiteStatus,
skinSiteUser,
waitForSkinSiteSession,
} from '../composables/skin-site-session.ts'
import { authenticateInstancePlayer } from './instance-player.ts'
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
@@ -24,10 +27,36 @@ function startHostedPackAttempt(instanceId: string) {
}
export async function requireHostedGameAccount(): Promise<void> {
const available = await invokeHosted<boolean>('plugin:install|hosted_has_game_account')
if (!available) {
throw new Error(HOSTED_ACCOUNT_REQUIRED_MESSAGE)
if (await invokeHosted<boolean>('plugin:install|hosted_has_game_account')) return
// No game credential yet, but a signed-in skin site already lists the
// user's players. Authenticate the first one automatically so the user
// does not have to pick a player manually before creating an instance.
await waitForSkinSiteSession()
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
if (skinSitePlayers.value.length === 0) {
await requestSkinSitePlayers().catch(() => {})
}
const player =
skinSitePlayers.value.find((candidate) => !candidate.isMojang) ?? skinSitePlayers.value[0]
if (player) {
try {
await authenticateInstancePlayer({
id: player.uuid,
name: player.name,
account_type: 'yggdrasil',
skin_site_user: skinSiteUser.value.uuid,
})
} catch {
// Fall through to the login-required error below.
}
if (await invokeHosted<boolean>('plugin:install|hosted_has_game_account')) {
return
}
}
}
throw new Error(HOSTED_ACCOUNT_REQUIRED_MESSAGE)
}
export function clearHostedSession(): Promise<void> {
+98
View File
@@ -108,6 +108,104 @@ export async function get_many(instanceIds: string[]): Promise<GameInstance[]> {
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<SyncedOption, boolean>
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<GlobalSyncedOptions> {
return await invoke('plugin:instance|instance_get_synced_options')
}
export async function get_initialized_synced_options(): Promise<GlobalSyncedOptions> {
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<GlobalSyncedOptions> {
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<GameInstance> {
return await invoke('plugin:instance|instance_set_instance_synced_option', {
instanceId,
option,
enabled,
resolution,
})
}
export async function get_synced_options_overview(
instanceId: string,
): Promise<SyncedOptionsOverview> {
return await invoke('plugin:instance|instance_get_synced_options_overview', { instanceId })
}
export async function get_command_history(): Promise<string> {
return await invoke('plugin:instance|instance_get_synced_command_history')
}
export async function set_command_history(contents: string): Promise<string> {
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<SyncedServer[]> {
return await invoke('plugin:instance|instance_list_synced_servers')
}
export async function update_synced_server(server: SyncedServer): Promise<void> {
await invoke('plugin:instance|instance_update_synced_server', { server })
}
export async function remove_synced_server(id: string): Promise<void> {
await invoke('plugin:instance|instance_remove_synced_server', { id })
}
export async function get_projects(
instanceId: string,
cacheBehaviour?: CacheBehaviour,
@@ -64,3 +64,6 @@ export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now())
export function announcementKey(item: RemoteAnnouncement) {
return item.id + ':' + item.published_at
}
export const REMOTE_ANNOUNCEMENTS_UPDATED_EVENT = 'starlight:remote-announcements-updated'
export const OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT = 'starlight:open-remote-announcement-center'
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts'
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
function withStorage(run: (values: Map<string, string>) => void): void {
const values = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
})
try {
run(values)
} finally {
if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage)
else delete (globalThis as { localStorage?: Storage }).localStorage
}
}
test('armor preview selections survive storage round trips', () => {
withStorage(() => {
const config = loadSkinArmorPreview()
config.helmet.material = 'diamond'
config.helmet.trimPattern = 'sentry'
config.helmet.trimMaterial = 'gold'
saveSkinArmorPreview(config)
assert.deepEqual(loadSkinArmorPreview(), config)
})
})
test('invalid stored armor values fall back per slot and field', () => {
withStorage((values) => {
values.set(
'axolotl:skin-armor-preview',
JSON.stringify({
helmet: { material: 'diamond', trimPattern: 'invalid', trimMaterial: 'gold' },
boots: { material: 'turtle', trimPattern: 'sentry', trimMaterial: 'invalid' },
}),
)
const config = loadSkinArmorPreview()
assert.deepEqual(config.helmet, {
material: 'diamond',
trimPattern: null,
trimMaterial: 'gold',
})
assert.deepEqual(config.boots, {
material: null,
trimPattern: 'sentry',
trimMaterial: 'iron',
})
assert.equal(config.chestplate.material, null)
})
})
@@ -0,0 +1,47 @@
import {
ARMOR_SLOTS,
ARMOR_TRIM_MATERIALS,
ARMOR_TRIM_PATTERNS,
armorMaterialsForSlot,
type ArmorPreviewConfig,
createDefaultArmorPreviewConfig,
} from '@modrinth/ui'
const STORAGE_KEY = 'axolotl:skin-armor-preview'
function includes<T extends string>(values: readonly T[], value: unknown): value is T {
return typeof value === 'string' && values.some((item) => item === value)
}
export function loadSkinArmorPreview(): ArmorPreviewConfig {
const config = createDefaultArmorPreviewConfig()
try {
const saved = JSON.parse(globalThis.localStorage?.getItem(STORAGE_KEY) ?? 'null')
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return config
for (const slot of ARMOR_SLOTS) {
const piece = saved[slot]
if (!piece || typeof piece !== 'object' || Array.isArray(piece)) continue
if (piece.material === null || includes(armorMaterialsForSlot(slot), piece.material)) {
config[slot].material = piece.material
}
if (piece.trimPattern === null || includes(ARMOR_TRIM_PATTERNS, piece.trimPattern)) {
config[slot].trimPattern = piece.trimPattern
}
if (includes(ARMOR_TRIM_MATERIALS, piece.trimMaterial)) {
config[slot].trimMaterial = piece.trimMaterial
}
}
} catch {
// Invalid or unavailable local storage falls back to a fresh preview.
}
return config
}
export function saveSkinArmorPreview(config: ArmorPreviewConfig): void {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(config))
} catch {
// The preview remains usable when local storage is unavailable.
}
}
@@ -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 ?? '',
)
}
@@ -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<ContentItem[]> {
const items = await invoke<ContentItem[]>('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<PackSyncPreview> {
return invoke('plugin:instance|instance_get_pack_sync_preview', { instanceId, projectPath })
}
export function sync_pack(instanceId: string, projectPath: string): Promise<void> {
return invoke('plugin:instance|instance_sync_pack', { instanceId, projectPath })
}
export function desync_pack(
instanceId: string,
packId: string,
mode: DesyncServerMode,
): Promise<void> {
return invoke('plugin:instance|instance_desync_pack', { instanceId, packId, mode })
}
export function upload_synced_pack(
path: string,
projectType: SyncedPackType,
gameVersions: string[],
): Promise<void> {
return invoke('plugin:instance|instance_upload_synced_pack', { path, projectType, gameVersions })
}
export function set_synced_pack_enabled(packId: string, enabled: boolean): Promise<void> {
return invoke('plugin:instance|instance_set_synced_pack_enabled', { packId, enabled })
}
export function remove_synced_pack(packId: string): Promise<void> {
return invoke('plugin:instance|instance_remove_synced_pack', { packId })
}
+14
View File
@@ -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
@@ -44,6 +55,7 @@ export type GameInstance = {
force_fullscreen?: boolean
maximize_window?: boolean
game_resolution?: [number, number]
window_title?: string | null
launch_preparation_timeout?: number | null
hooks: Hooks
}
@@ -228,6 +240,8 @@ type AppSettings = {
force_fullscreen: boolean
maximize_window: boolean
game_resolution: [number, number]
custom_window_title_enabled: boolean
default_window_title: string
hide_on_process_start: boolean
enter_lightweight_mode_on_game_launch: boolean
auto_set_java_high_performance_mode: boolean
File diff suppressed because it is too large. Load diff
+435 -2
View File
@@ -10929,5 +10929,438 @@
},
"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": "选择一个值" },
"app.anniversary.banner": { "message": "Starlight {ordinal} 周年快乐!" },
"instance.settings.tabs.window.window-title": { "message": "窗口标题" },
"instance.settings.tabs.window.window-title.description": { "message": "自定义 Minecraft 窗口标题。留空则使用默认标题。" },
"instance.settings.tabs.window.window-title.enter": { "message": "输入窗口标题..." },
"app.settings.defaults.window-title.enabled": { "message": "自定义 Minecraft 窗口标题" },
"app.settings.defaults.window-title.enabled-description": { "message": "允许实例自定义 Minecraft 窗口标题。" },
"app.settings.defaults.window-title.default": { "message": "默认窗口标题" },
"app.settings.defaults.window-title.default-description": { "message": "当实例未提供自己的标题时使用。" },
"app.settings.defaults.window-title.default-placeholder": { "message": "输入默认窗口标题..." },
"app.action-bar.announcements": { "message": "公告" },
"app.remote-announcements.center-title": { "message": "公告" },
"app.remote-announcements.empty": { "message": "暂无公告" }
}
+143 -29
View File
@@ -9,9 +9,13 @@ import {
SpinnerIcon,
} from '@modrinth/assets'
import {
type ArmorPreviewConfig,
ArmorPreviewControls,
ButtonStyled,
cloneArmorPreviewConfig,
commonMessages,
ConfirmModal,
createDefaultArmorPreviewConfig,
defineMessages,
injectNotificationManager,
NavTabs,
@@ -24,12 +28,14 @@ import { invoke } from '@tauri-apps/api/core'
import type { DragDropEvent } from '@tauri-apps/api/webview'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import type { ComponentPublicInstance, Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import ArmorTrimTab from '@/components/ui/skin/ArmorTrimTab.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SkinListSkeleton from '@/components/ui/skin/SkinListSkeleton.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import {
openSkinSiteLogin,
@@ -45,6 +51,7 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { check_reachable, get_default_user, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { loadSkinArmorPreview, saveSkinArmorPreview } from '@/helpers/skin-armor-preview'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
import {
determineModelType,
@@ -252,6 +259,8 @@ const editSkinModal = useTemplateRef('editSkinModal')
const addSkinFileInput = useTemplateRef<HTMLInputElement>('addSkinFileInput')
const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectionList')
const skinPreviewArea = useTemplateRef<HTMLElement>('skinPreviewArea')
const armorTrimTab = useTemplateRef<ComponentPublicInstance>('armorTrimTab')
const armorPreviewControls = useTemplateRef<ComponentPublicInstance>('armorPreviewControls')
const { formatMessage } = useVIntl()
const router = useRouter()
@@ -391,7 +400,8 @@ const isAddSkinButtonDragActive = ref(false)
const deleteSkinModal = ref()
const skinToDelete = ref<Skin | null>(null)
const skinListTab = ref<'saved' | 'default'>('saved')
const lastSkinListTab = ref<'saved' | 'default'>('saved')
const skinListTab = ref<'saved' | 'default' | 'armor'>('saved')
const skinListTabLinks = computed(() => [
{
@@ -403,6 +413,70 @@ const skinListTabLinks = computed(() => [
label: formatMessage(messages.defaultTab),
},
])
const ARMOR_TRIM_PANEL_ID = 'skins-armor-trim-panel'
const savedArmorPreview = ref<ArmorPreviewConfig>(loadSkinArmorPreview())
const armorPreviewConfig = ref<ArmorPreviewConfig>(cloneArmorPreviewConfig(savedArmorPreview.value))
function saveArmorPreview() {
savedArmorPreview.value = cloneArmorPreviewConfig(armorPreviewConfig.value)
saveSkinArmorPreview(savedArmorPreview.value)
}
function resetArmorPreviewToSaved() {
armorPreviewConfig.value = cloneArmorPreviewConfig(savedArmorPreview.value)
}
function restoreDefaultArmorPreview() {
armorPreviewConfig.value = createDefaultArmorPreviewConfig()
}
/** A third tab behind the two list tabs, opened from the preview's action row. */
function openArmorTab() {
if (skinListTab.value !== 'armor') lastSkinListTab.value = skinListTab.value
skinListTab.value = 'armor'
}
/**
* Whether an element belongs to the armour surface: somewhere inside the open
* armour tab or the button that toggles it. Only those hold a focus ring that
* would be left behind when the tab closes.
*/
function isArmorPreviewElement(element: Element) {
const tabElement: unknown = armorTrimTab.value?.$el
const toggleElement: unknown = armorPreviewControls.value?.$el
return (
(tabElement instanceof Element && tabElement.contains(element)) ||
(toggleElement instanceof Element && toggleElement.contains(element))
)
}
function closeArmorTab() {
// Leaving the tab drops whatever was not saved on purpose.
resetArmorPreviewToSaved()
skinListTab.value = lastSkinListTab.value
// Escape and the toggle both leave the armour button holding focus, which
// keeps its focus ring drawn until something else is clicked. Only drop that
// ring when the focus sits on the armour surface, never on an unrelated
// control that happened to hold it when Escape was pressed.
const focused = document.activeElement
if (focused instanceof HTMLElement && isArmorPreviewElement(focused)) focused.blur()
}
/**
* The tab bar keeps the last list tab highlighted while the armour tab shows, so
* picking one of them leaves the armour tab. That transition has to drop the
* draft exactly like the toggle and Escape paths do, or it lingers on the model
* while the panel is gone.
*/
function selectSkinListTab(index: number) {
if (skinListTab.value === 'armor') resetArmorPreviewToSaved()
lastSkinListTab.value = index === 0 ? 'saved' : 'default'
skinListTab.value = lastSkinListTab.value
}
function confirmDeleteSkin(skin: Skin) {
if (isSkinManagementReadOnly.value) return
@@ -1211,6 +1285,9 @@ await loadSkins()
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
>
<SkinPreviewRenderer
v-model:armor-config="armorPreviewConfig"
armor-preview
:armor-controls="false"
:cape-src="capeTexture"
:texture-src="skinTexture || ''"
:variant="skinVariant"
@@ -1257,6 +1334,16 @@ await loadSkins()
<EditIcon />
{{ formatMessage(messages.editSkinButton) }}
</button>
<ArmorPreviewControls
v-if="!isSkinSiteProfile"
ref="armorPreviewControls"
v-model="armorPreviewConfig"
panel="external"
:panel-id="ARMOR_TRIM_PANEL_ID"
:open="skinListTab === 'armor'"
@open="openArmorTab"
@close="closeArmorTab"
/>
</template>
</SkinPreviewRenderer>
</div>
@@ -1265,14 +1352,10 @@ await loadSkins()
<div class="pt-2">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<NavTabs
:active-index="skinListTab === 'saved' ? 0 : 1"
:active-index="lastSkinListTab === 'saved' ? 0 : 1"
:links="skinListTabLinks"
mode="local"
@tab-click="
(index: number) => {
skinListTab = index === 0 ? 'saved' : 'default'
}
"
@tab-click="selectSkinListTab"
/>
<ButtonStyled v-if="!isSkinSiteProfile" color="brand">
<button @click="router.push('/lab/skin-editor')">
@@ -1281,27 +1364,45 @@ await loadSkins()
</button>
</ButtonStyled>
</div>
<VirtualSkinSectionList
ref="skinSectionList"
:active-tab="skinListTab"
:saved-skins="savedSkins"
:default-skin-sections="defaultSkinSections"
:get-baked-skin-textures="getBakedSkinTextures"
:is-skin-selected="isSkinSelected"
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:read-only="isSkinManagementReadOnly"
:manage-saved-skins="!isSkinSiteProfile"
@select="changeSkin"
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
@add-skin-dragleave="onAddSkinDragLeave"
@add-skin-drop="onAddSkinDrop"
/>
<Transition name="armor-tab" mode="out-in">
<div v-if="skinListTab !== 'armor'" key="list">
<SkinListSkeleton v-if="isSkinListBuffering" />
<VirtualSkinSectionList
v-show="!isSkinListBuffering"
ref="skinSectionList"
:active-tab="skinListTab"
:saved-skins="savedSkins"
:default-skin-sections="defaultSkinSections"
:get-baked-skin-textures="getBakedSkinTextures"
:is-skin-selected="isSkinSelected"
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:read-only="isSkinManagementReadOnly"
:manage-saved-skins="!isSkinSiteProfile"
@select="changeSkin"
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
@add-skin-dragleave="onAddSkinDragLeave"
@add-skin-drop="onAddSkinDrop"
/>
</div>
<ArmorTrimTab
v-else
ref="armorTrimTab"
key="armor"
v-model="armorPreviewConfig"
:panel-id="ARMOR_TRIM_PANEL_ID"
:saved="savedArmorPreview"
@save="saveArmorPreview"
@reset="resetArmorPreviewToSaved"
@defaults="restoreDefaultArmorPreview"
@close="closeArmorTab"
/>
</Transition>
</div>
</div>
@@ -1382,4 +1483,17 @@ await loadSkins()
grid-template-columns: 1fr;
}
}
// The armour tab crossfades with the skin list it replaces.
.armor-tab-enter-active,
.armor-tab-leave-active {
transition:
opacity 180ms ease,
transform 180ms ease;
}
.armor-tab-enter-from,
.armor-tab-leave-to {
opacity: 0;
transform: translateY(0.5rem);
}
</style>
@@ -0,0 +1,2 @@
export type { InstanceSettingsContext } from '@/providers/instance-settings'
export { injectInstanceSettings, provideInstanceSettings } from '@/providers/instance-settings'
@@ -0,0 +1,18 @@
import type { InjectionKey } from 'vue'
export type UnsavedChangesController = {
hasChanges: () => boolean
getOriginal: () => Record<string, unknown>
getModified: () => Record<string, unknown>
isSaving: () => boolean
reset: () => void
save: () => void | Promise<void>
}
export type AppSettingsModalContext = {
close: () => boolean
registerUnsavedChangesController: (controller: UnsavedChangesController | null) => void
}
export const appSettingsModalContextKey: InjectionKey<AppSettingsModalContext> =
Symbol('appSettingsModalContext')
@@ -8,7 +8,7 @@ export interface InstanceSettingsContext {
offline?: boolean
isMinecraftServer: Ref<boolean>
onUnlinked: () => void
closeModal?: () => void
closeModal?: (onAfterClose?: () => void) => void
}
export const [injectInstanceSettings, provideInstanceSettings] =
+1
View File
@@ -25,6 +25,7 @@ export const DEFAULT_FEATURE_FLAGS = {
page_transitions: true,
advanced_filters_collapsed: true,
auto_install_dependencies: true,
force_anniversary_celebration: false,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging
version = "1.0.0-beta15"
version = "1.0.2-beta4"
edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red"
+27
View File
@@ -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,
+277 -1
View File
@@ -135,6 +135,33 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
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<String>,
pub linked_version_json_path: Option<String>,
pub linked_game_dir_mode: Option<String>,
pub synced_options: theseus::instance::InstanceSyncedOptions,
}
#[derive(Deserialize, Debug, Clone)]
@@ -305,6 +333,12 @@ pub struct EditInstance {
)]
pub launch_preparation_timeout: Option<Option<u64>>,
pub hooks: Option<Hooks>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub window_title: Option<Option<String>>,
#[serde(
default,
@@ -362,6 +396,7 @@ impl From<InstanceMetadata> for Instance {
.instance
.linked_version_json_path,
linked_game_dir_mode: metadata.instance.linked_game_dir_mode,
synced_options: metadata.synced_options,
}
}
}
@@ -502,7 +537,8 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
|| edit_instance.maximize_window.is_some()
|| edit_instance.game_resolution.is_some()
|| edit_instance.launch_preparation_timeout.is_some()
|| edit_instance.hooks.is_some();
|| edit_instance.hooks.is_some()
|| edit_instance.window_title.is_some();
if has_launch_change {
Some(InstanceLaunchOverridesPatch {
player: None,
@@ -517,6 +553,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
launch_preparation_timeout: edit_instance
.launch_preparation_timeout,
hooks: edit_instance.hooks,
window_title: edit_instance.window_title,
})
} else {
None
@@ -1833,3 +1870,242 @@ pub async fn instance_get_backup_delete_summary(
) -> Result<theseus::instance::BackupDeleteSummary> {
Ok(theseus::instance::get_backup_delete_summary(instance_id).await?)
}
#[tauri::command]
pub async fn instance_get_synced_options()
-> Result<theseus::instance::GlobalSyncedOptions> {
Ok(theseus::instance::get_global_synced_options().await?)
}
#[tauri::command]
pub async fn instance_get_initialized_synced_options()
-> Result<theseus::instance::GlobalSyncedOptions> {
Ok(theseus::instance::get_initialized_synced_options().await?)
}
#[tauri::command]
pub async fn instance_get_synced_game_options_config()
-> Result<theseus::instance::GameSettingsEditorState> {
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<theseus::instance::GameSettingsEditorState> {
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<theseus::instance::SaveGameSettingsResult> {
Ok(theseus::instance::save_synced_game_option_changes(request).await?)
}
#[tauri::command]
pub async fn instance_list_game_options_sync_sources()
-> Result<Vec<theseus::instance::GameOptionsSourceCandidate>> {
Ok(theseus::instance::list_game_options_sync_sources().await?)
}
#[tauri::command]
pub async fn instance_get_game_setting_locale_labels(
instance_id: Option<String>,
locale: String,
option_ids: Vec<String>,
refresh_sources: bool,
) -> Result<theseus::instance::GameSettingLocaleLabels> {
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<theseus::instance::GameSettingsEditorState> {
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<theseus::instance::GameSettingsEditorState> {
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<theseus::instance::SaveGameSettingsResult> {
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<theseus::instance::PackSyncPreview> {
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<Vec<theseus::data::ContentItem>> {
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<String>,
) -> 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<theseus::instance::SyncedOptionsOverview> {
Ok(theseus::instance::get_synced_options_overview(&instance_id).await?)
}
#[tauri::command]
pub async fn instance_get_synced_option_capabilities(
instance_id: String,
) -> Result<Vec<theseus::instance::SyncedOptionCapability>> {
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<theseus::instance::SyncedOptionJoinPreview> {
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<String>,
) -> Result<theseus::instance::GlobalSyncedOptions> {
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<theseus::instance::SyncedOptionJoinResolution>,
) -> Result<Instance> {
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<String> {
Ok(theseus::instance::get_synced_command_history().await?)
}
#[tauri::command]
pub async fn instance_set_synced_command_history(
contents: String,
) -> Result<String> {
Ok(theseus::instance::set_synced_command_history(&contents).await?)
}
#[tauri::command]
pub async fn instance_list_synced_servers()
-> Result<Vec<theseus::instance::SyncedServer>> {
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(())
}
+79 -1
View File
@@ -253,6 +253,14 @@ impl LightweightMode {
if payload.maximize_window {
maximize_minecraft_window(payload.pid).await;
}
if let Some(title) = payload
.window_title
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
rename_minecraft_window(payload.pid, title).await;
}
let settings = match theseus::settings::get().await {
Ok(settings) => settings,
Err(error) => {
@@ -332,6 +340,8 @@ struct ProcessEventPayload {
pid: u32,
#[serde(default)]
maximize_window: bool,
#[serde(default)]
window_title: Option<String>,
event: String,
crashed: Option<bool>,
#[serde(default)]
@@ -339,7 +349,7 @@ struct ProcessEventPayload {
}
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering};
#[cfg(target_os = "windows")]
static MAXIMIZE_PROCESS_ID: AtomicU32 = AtomicU32::new(0);
@@ -400,6 +410,74 @@ async fn maximize_minecraft_window(pid: u32) {
#[cfg(not(target_os = "windows"))]
async fn maximize_minecraft_window(_pid: u32) {}
#[cfg(target_os = "windows")]
static RENAME_WINDOW_HANDLE: AtomicIsize = AtomicIsize::new(0);
#[cfg(target_os = "windows")]
unsafe extern "system" fn rename_if_owned_by_process(
hwnd: windows::Win32::Foundation::HWND,
_: windows::Win32::Foundation::LPARAM,
) -> windows::core::BOOL {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsWindowVisible,
};
use windows::core::BOOL;
let mut window_pid = 0;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut window_pid)) };
if window_pid == MAXIMIZE_PROCESS_ID.load(Ordering::Relaxed)
&& unsafe { IsWindowVisible(hwnd).as_bool() }
{
RENAME_WINDOW_HANDLE.store(hwnd.0 as isize, Ordering::Relaxed);
return BOOL(0);
}
BOOL(1)
}
#[cfg(target_os = "windows")]
async fn rename_minecraft_window(pid: u32, title: &str) {
if pid == 0 || title.is_empty() {
return;
}
// PCL keeps re-applying the title: FML/Quilt and some mods reset it
// while the game boots, so a one-shot rename is not enough.
let deadline =
tokio::time::Instant::now() + std::time::Duration::from_secs(45);
let mut applied = false;
loop {
let found = {
let _guard = MAXIMIZE_WINDOW_ENUMERATION.lock();
MAXIMIZE_PROCESS_ID.store(pid, Ordering::Relaxed);
RENAME_WINDOW_HANDLE.store(0, Ordering::Relaxed);
unsafe {
use windows::Win32::Foundation::LPARAM;
use windows::Win32::UI::WindowsAndMessaging::EnumWindows;
let _ =
EnumWindows(Some(rename_if_owned_by_process), LPARAM(0));
}
RENAME_WINDOW_HANDLE.load(Ordering::Relaxed)
};
if found != 0 {
let hwnd = windows::Win32::Foundation::HWND(found as *mut _);
let wide: Vec<u16> = title.encode_utf16().chain(Some(0)).collect();
unsafe {
use windows::Win32::UI::WindowsAndMessaging::SetWindowTextW;
let _ =
SetWindowTextW(hwnd, windows::core::PCWSTR(wide.as_ptr()));
}
applied = true;
}
if tokio::time::Instant::now() >= deadline {
return;
}
let _ = applied;
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
}
}
#[cfg(not(target_os = "windows"))]
async fn rename_minecraft_window(_pid: u32, _title: &str) {}
#[tauri::command]
pub fn lightweight_mode_frontend_ready(
+158 -1
View File
@@ -1,6 +1,163 @@
{
"updated_at": "2026-10-01T05:02:05.765Z",
"updated_at": "2026-10-03T07:08:29.010Z",
"announcements": [
{
"id": "launcher-1.0.1",
"version": "1.0.1",
"publishedAt": "2026-10-03",
"title": {
"en-US": "Starlight Launcher 1.0.1",
"zh-CN": "Starlight Launcher 1.0.1"
},
"changes": {
"added": [
{
"en-US": "Instance backups: create deduplicated snapshots of an instance while it is closed, preview and restore them, exclude specific files or folders, and move the backup repository.",
"zh-CN": "实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。"
},
{
"en-US": "Customize the launcher's UI font and monospace font in appearance settings.",
"zh-CN": "在外观设置中自定义启动器界面字体与等宽字体。"
}
],
"fixed": [
{
"en-US": "Fixed beta builds not auto-updating from beta9 to beta10 (prerelease versions were compared as text instead of by number).",
"zh-CN": "修复 beta 版本无法从 beta9 自动更新到 beta10 的问题(预发布版本此前按文本而非数字比较)。"
},
{
"en-US": "Fixed a cross-crate visibility issue that prevented the launcher from compiling with the ignore-SSL-errors option.",
"zh-CN": "修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。"
}
]
}
},
{
"id": "launcher-1.0.1-beta10",
"version": "1.0.1-beta10",
"publishedAt": "2026-10-02",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta10",
"zh-CN": "Starlight Launcher 1.0.1-beta10"
},
"changes": {
"added": [
{
"en-US": "Reworked the instance-page tour into four steps covering content, files, worlds, and logs.",
"zh-CN": "重做实例页导览,分为内容、文件、世界、日志四步。"
},
{
"en-US": "When the install location is a drive root, the installer now creates a Starlight subfolder automatically.",
"zh-CN": "安装位置选到盘符根目录时,安装器会自动创建 Starlight 子文件夹。"
}
],
"fixed": [
{
"en-US": "Fixed the launcher UI scale reverting after a restart.",
"zh-CN": "修复启动器界面缩放重启后恢复原状的问题。"
},
{
"en-US": "Fixed developer feature flags not saving.",
"zh-CN": "修复开发者功能开关无法保存的问题。"
},
{
"en-US": "Fixed instance group edits not saving for instances without an applied content set.",
"zh-CN": "修复没有应用内容集的实例无法保存分组修改的问题。"
},
{
"en-US": "Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.",
"zh-CN": "修复前后端功能开关不同步导致启动器启动崩溃的问题。"
},
{
"en-US": "Fixed being unable to copy text inside the embedded skin site.",
"zh-CN": "修复内嵌皮肤站中无法复制文本的问题。"
},
{
"en-US": "Fixed the onboarding prompt not dismissing when clicking anywhere.",
"zh-CN": "修复导览提示点击任意位置无法关闭的问题。"
}
]
}
},
{
"id": "launcher-1.0.1-beta9",
"version": "1.0.1-beta9",
"publishedAt": "2026-10-02",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta9",
"zh-CN": "Starlight Launcher 1.0.1-beta9"
},
"changes": {
"fixed": [
{
"en-US": "Fixed a crash on the minimal home layout caused by an unrecognized multiplayer feature flag (missing backend enum variant).",
"zh-CN": "修复多人联机功能开关缺少后端枚举变体、导致精简主页报错的问题。"
}
]
}
},
{
"id": "launcher-1.0.1-beta8",
"version": "1.0.1-beta8",
"publishedAt": "2026-10-02",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta8",
"zh-CN": "Starlight Launcher 1.0.1-beta8"
},
"changes": {
"fixed": [
{
"en-US": "Fixed the sidebar navigation entries breaking after the screenshot center was added.",
"zh-CN": "修复添加截图中心后侧边栏导航项损坏的问题。"
},
{
"en-US": "Fixed being unable to install the StarLight hosted pack even after signing in (missing command permission).",
"zh-CN": "修复登录后仍无法安装 StarLight 实例的问题(命令权限缺失)。"
},
{
"en-US": "Fixed the launcher UI scaling not applying when changed (missing command permission).",
"zh-CN": "修复修改启动器界面缩放后不生效的问题(命令权限缺失)。"
},
{
"en-US": "Fixed several screenshot center actions failing due to missing command permissions.",
"zh-CN": "修复截图中心部分操作因命令权限缺失而失败的问题。"
}
]
}
},
{
"id": "launcher-1.0.1-beta7",
"version": "1.0.1-beta7",
"publishedAt": "2026-10-02",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta7",
"zh-CN": "Starlight Launcher 1.0.1-beta7"
},
"changes": {
"added": [
{
"en-US": "Added a screenshot center: browse screenshots across every instance, group them (custom groups, by instance, or by date), preview, edit (crop, rotate, annotate, censor), and export as ZIP.",
"zh-CN": "新增截图中心:跨实例浏览截图,支持自定义/按实例/按日期分组,可预览、编辑(裁剪、标注、打码等)并导出为 ZIP。"
},
{
"en-US": "Added the multiplayer (Terracotta) entry to the sidebar, behind a feature flag.",
"zh-CN": "侧边栏新增多人联机(陶瓦)入口,由功能开关控制。"
}
],
"changed": [
{
"en-US": "Launcher UI scaling is now persisted in the backend and applied via native webview zoom, so it scales embedded pages correctly and keeps onboarding anchors aligned.",
"zh-CN": "启动器界面缩放改为后端持久化,并通过原生 webview 缩放应用,可正确缩放内嵌页面且导览锚点不再偏移。"
}
],
"fixed": [
{
"en-US": "Fixed the onboarding tour skipping its first step when the target anchor was missing.",
"zh-CN": "修复导览首步在目标锚点缺失时被自动跳过的问题。"
}
]
}
},
{
"id": "launcher-1.0.1-beta6",
"version": "1.0.1-beta6",
+27 -27
View File
@@ -1,32 +1,32 @@
{
"tag_name": "v1.0.0",
"tag_name": "v1.0.1",
"assets": [
"Axolotl_Launcher_1.0.0_x64_modern-setup.exe",
"Axolotl_Launcher_1.0.0_x64_modern-setup.exe.sig",
"Axolotl_Launcher_1.0.0_x64_nsis-setup.exe",
"Axolotl_Launcher_1.0.0_x64_nsis-setup.exe.sig",
"Axolotl_Launcher_1.0.0_x64_portable.zip",
"Starlight.Launcher-1.0.0-1.aarch64.rpm",
"Starlight.Launcher-1.0.0-1.aarch64.rpm.sig",
"Starlight.Launcher-1.0.0-1.x86_64.rpm",
"Starlight.Launcher-1.0.0-1.x86_64.rpm.sig",
"Starlight.Launcher_1.0.0_aarch64.AppImage",
"Starlight.Launcher_1.0.0_aarch64.AppImage.sig",
"Starlight.Launcher_1.0.0_aarch64.AppImage.tar.gz",
"Starlight.Launcher_1.0.0_aarch64.AppImage.tar.gz.sig",
"Starlight.Launcher_1.0.0_amd64.AppImage",
"Starlight.Launcher_1.0.0_amd64.AppImage.sig",
"Starlight.Launcher_1.0.0_amd64.AppImage.tar.gz",
"Starlight.Launcher_1.0.0_amd64.AppImage.tar.gz.sig",
"Starlight.Launcher_1.0.0_amd64.deb",
"Starlight.Launcher_1.0.0_amd64.deb.sig",
"Starlight.Launcher_1.0.0_arm64.deb",
"Starlight.Launcher_1.0.0_arm64.deb.sig",
"Starlight.Launcher_1.0.0_universal.dmg",
"Starlight.Launcher_1.0.0_x64-setup.exe",
"Starlight.Launcher_1.0.0_x64-setup.exe.sig",
"Starlight.Launcher_1.0.0_x64-setup.nsis.zip",
"Starlight.Launcher_1.0.0_x64-setup.nsis.zip.sig",
"Axolotl_Launcher_1.0.1_x64_modern-setup.exe",
"Axolotl_Launcher_1.0.1_x64_modern-setup.exe.sig",
"Axolotl_Launcher_1.0.1_x64_nsis-setup.exe",
"Axolotl_Launcher_1.0.1_x64_nsis-setup.exe.sig",
"Axolotl_Launcher_1.0.1_x64_portable.zip",
"Starlight.Launcher-1.0.1-1.aarch64.rpm",
"Starlight.Launcher-1.0.1-1.aarch64.rpm.sig",
"Starlight.Launcher-1.0.1-1.x86_64.rpm",
"Starlight.Launcher-1.0.1-1.x86_64.rpm.sig",
"Starlight.Launcher_1.0.1_aarch64.AppImage",
"Starlight.Launcher_1.0.1_aarch64.AppImage.sig",
"Starlight.Launcher_1.0.1_aarch64.AppImage.tar.gz",
"Starlight.Launcher_1.0.1_aarch64.AppImage.tar.gz.sig",
"Starlight.Launcher_1.0.1_amd64.AppImage",
"Starlight.Launcher_1.0.1_amd64.AppImage.sig",
"Starlight.Launcher_1.0.1_amd64.AppImage.tar.gz",
"Starlight.Launcher_1.0.1_amd64.AppImage.tar.gz.sig",
"Starlight.Launcher_1.0.1_amd64.deb",
"Starlight.Launcher_1.0.1_amd64.deb.sig",
"Starlight.Launcher_1.0.1_arm64.deb",
"Starlight.Launcher_1.0.1_arm64.deb.sig",
"Starlight.Launcher_1.0.1_universal.dmg",
"Starlight.Launcher_1.0.1_x64-setup.exe",
"Starlight.Launcher_1.0.1_x64-setup.exe.sig",
"Starlight.Launcher_1.0.1_x64-setup.nsis.zip",
"Starlight.Launcher_1.0.1_x64-setup.nsis.zip.sig",
"Starlight.Launcher_universal.app.tar.gz",
"Starlight.Launcher_universal.app.tar.gz.sig"
]
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "theseus"
# The actual version is set by the theseus-build workflow on tagging
version = "1.0.0-beta15"
version = "1.0.2-beta4"
edition.workspace = true
[dependencies]
@@ -0,0 +1,217 @@
-- 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
);
-- Synced pack catalog tables (ported alongside the sync feature tables).
CREATE TABLE synced_pack_catalog (
id TEXT PRIMARY KEY NOT NULL,
project_type TEXT NOT NULL CHECK (project_type IN ('resourcepack', 'datapack')),
file_name TEXT NOT NULL,
sha1 TEXT NOT NULL UNIQUE,
size INTEGER NOT NULL CHECK (size >= 0),
game_versions_json TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL
);
CREATE INDEX synced_pack_catalog_type ON synced_pack_catalog(project_type, modified_at);
CREATE TABLE synced_pack_instances (
pack_id TEXT NOT NULL,
instance_id TEXT NOT NULL,
excluded INTEGER NOT NULL DEFAULT 0 CHECK (excluded IN (0, 1)),
materialized_path TEXT,
modified_at INTEGER NOT NULL,
PRIMARY KEY (pack_id, instance_id),
FOREIGN KEY (pack_id) REFERENCES synced_pack_catalog(id) ON DELETE CASCADE,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
);
CREATE INDEX synced_pack_instances_instance ON synced_pack_instances(instance_id, excluded);
ALTER TABLE settings ADD COLUMN sync_features_across_devices INTEGER NOT NULL DEFAULT FALSE;
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,2 @@
ALTER TABLE settings ADD COLUMN custom_window_title_enabled INTEGER NOT NULL DEFAULT FALSE;
ALTER TABLE settings ADD COLUMN default_window_title TEXT NOT NULL DEFAULT 'Minecraft';
+52
View File
@@ -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};
+12
View File
@@ -250,6 +250,17 @@ async fn run_credentials(
.launch_overrides
.maximize_window
.unwrap_or(settings.maximize_window);
let window_title = settings
.custom_window_title_enabled
.then(|| {
context
.launch_overrides
.window_title
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| settings.default_window_title.clone())
})
.filter(|value| !value.trim().is_empty());
let env_args = context
.launch_overrides
.custom_env_vars
@@ -319,6 +330,7 @@ async fn run_credentials(
&memory,
&resolution,
maximize_window,
window_title,
credentials,
post_exit_hook,
&context,
@@ -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<String> {
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<String> {
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::<Vec<_>>();
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::<Vec<_>>();
let available = COMMAND_HISTORY_LIMIT.saturating_sub(canonical_lines.len());
let mut seen = canonical_lines
.iter()
.copied()
.collect::<std::collections::HashSet<_>>();
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)
}
@@ -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<Vec<u8>>,
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<Self> {
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<PathBuf> {
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<bool> {
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<LinkMode> {
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<Option<SyncCheckpoint>> {
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<String> {
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<NbtCompound> {
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<Vec<u8>> {
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<u8>,
) -> crate::Result<NbtCompound> {
let (root, _) = quartz_nbt::io::read_nbt(
&mut Cursor::new(bytes),
quartz_nbt::io::Flavor::Uncompressed,
)?;
Ok(root)
}
@@ -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<String>,
pub game_version: Option<String>,
pub eligible: bool,
pub disabled_reason: Option<GameOptionsSourceIssue>,
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<f64>,
pub max: Option<f64>,
pub step: Option<f64>,
pub unit: Option<String>,
pub choices: Vec<GameOptionEditorChoice>,
}
#[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<String>,
pub eventual_keys: Vec<String>,
pub game_versions: Vec<String>,
pub status: GameOptionCompatibilityStatus,
pub mapping: Option<GameOptionMappingKind>,
pub reason: Option<GameOptionCompatibilityReason>,
}
#[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<GameOptionCompatibilityBucket>,
}
#[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<String>,
pub sync_enabled: bool,
pub canonical_value: Option<CanonicalValue>,
pub value_state: GameOptionValueState,
pub option_revision: u64,
pub editor: GameOptionEditorDefinition,
pub compatibility: GameOptionCompatibility,
pub validation_error: Option<GameOptionValidationIssue>,
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<GameSettingCategory>,
pub settings: Vec<EditableGameSetting>,
}
#[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<GameSettingChange>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GameSettingChange {
pub option_id: String,
pub base_option_revision: u64,
pub sync_enabled: Option<bool>,
#[serde(
default,
with = "serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub canonical_value: Option<Option<CanonicalValue>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SaveGameSettingsResult {
pub state: Option<GameSettingsEditorState>,
pub applied: u32,
pub migrated: u32,
pub deferred: u32,
pub unsupported: u32,
pub failed: u32,
pub conflicts: Vec<String>,
}
#[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::<f64>().ok() == right.parse::<f64>().ok()
}
_ => left == right,
}
}
@@ -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,
),
];
@@ -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,
),
];
@@ -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"
),
];
@@ -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,
};
@@ -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",
];
@@ -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"),
];
@@ -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<Item = &'static SupportedSetting> {
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(),
}
}
@@ -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,
),
];
@@ -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<String> {
if raw.starts_with('"') {
serde_json::from_str(raw).ok()
} else {
Some(raw.to_string())
}
}
fn encode_string_token(value: &str) -> Option<String> {
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::<u32>())
.collect::<Result<Vec<_>, _>>()
.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<String> {
let (key, modifier) = split_key_binding(value);
let key = key.parse::<i32>().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<String> {
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::<i32>().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::<u32>())
.collect::<Result<Vec<_>, _>>();
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<CanonicalValue> {
match definition.encoding {
ValueEncoding::Bool => match raw {
"true" => Some(CanonicalValue::Bool(true)),
"false" => Some(CanonicalValue::Bool(false)),
_ => None,
},
ValueEncoding::Integer => {
raw.parse::<i64>().ok().map(CanonicalValue::Integer)
}
ValueEncoding::Decimal => raw
.parse::<f64>()
.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::<f64>().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::<i64>().ok().map(CanonicalValue::Integer)
}
ValueEncoding::MenuBackgroundBlur => raw
.parse::<f64>()
.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<String> {
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::<f64>()
.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::<f64>()
.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<CanonicalValue> {
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<String> {
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<String> {
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::<f64>().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::<i64>().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::<i64>()
.is_ok_and(|value| (0..=10).contains(&value))
} else {
raw.parse::<f64>().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()
}
}
@@ -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,
),
];
@@ -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<Option<bool>> {
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<bool> {
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(())
}
@@ -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<GameOptionsDocument>,
inspection_error: Option<String>,
}
/// 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<Vec<ParticipatingInstance>> {
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::<Vec<_>>();
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::<i32>()
.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<CanonicalValue>, 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)
}
}
@@ -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<String> {
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(())
}
@@ -0,0 +1,418 @@
//! Loads and saves the game settings stored by one unsynced instance.
use super::super as synced_options;
use super::CATALOG_REVISION;
use super::api_types::*;
use super::catalog::*;
use super::launch_overrides::currently_launcher_owned_keys;
use super::options_file::{
GameOptionsDocument, input_error, options_path, read_document, sha1_bytes,
validate_raw_key_value,
};
use super::read_instance_changes::custom_setting_id;
use crate::state::{CanonicalValue, GameOptionKind, InstanceMetadata, State};
use crate::util::io;
use std::collections::HashSet;
fn compatibility(controlled: bool) -> GameOptionCompatibility {
if controlled {
GameOptionCompatibility {
total_participating: 1,
will_receive: 0,
write_now: 0,
left_local: 1,
buckets: vec![GameOptionCompatibilityBucket {
instance_count: 1,
write_keys: Vec::new(),
eventual_keys: Vec::new(),
game_versions: Vec::new(),
status: GameOptionCompatibilityStatus::Controlled,
mapping: None,
reason: Some(GameOptionCompatibilityReason::LauncherControlled),
}],
}
} else {
GameOptionCompatibility {
total_participating: 1,
will_receive: 1,
write_now: 1,
left_local: 0,
buckets: Vec::new(),
}
}
}
fn categories(settings: &[EditableGameSetting]) -> Vec<GameSettingCategory> {
[
"skin_customization",
"video",
"language",
"music_and_sound",
"controls",
"chat",
"accessibility",
"online",
"custom",
]
.into_iter()
.filter(|id| settings.iter().any(|setting| setting.category_id == *id))
.map(|id| GameSettingCategory {
id: id.to_string(),
is_custom: id == "custom",
})
.collect()
}
fn editor_revision(metadata: &InstanceMetadata, input_bytes: &[u8]) -> String {
sha1_bytes(
format!(
"{CATALOG_REVISION}:{}:{}",
metadata.applied_content_set.game_version,
sha1_bytes(input_bytes)
)
.as_bytes(),
)
}
async fn load_instance_document(
instance_id: &str,
state: &State,
) -> crate::Result<(
InstanceMetadata,
GameOptionsDocument,
Vec<u8>,
HashSet<String>,
)> {
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| input_error("Unknown instance"))?;
if crate::state::game_options_sync_is_enabled(&state.pool).await?
&& metadata.synced_options.game_options
{
return Err(input_error(
"This instance is using synced game settings.",
));
}
if synced_options::sync_files_are_protected(&metadata) {
return Err(input_error(
"Game settings cannot be edited while this instance is installing or updating.",
));
}
let path = options_path(&metadata, state)?;
if !path.exists() {
return Err(input_error(
"Launch this instance once so Minecraft can create options.txt.",
));
}
let (document, input_bytes) = read_document(&path).await?;
let mut controlled_keys = currently_launcher_owned_keys(&metadata);
if metadata.launch_overrides.force_fullscreen.is_none()
&& crate::state::Settings::get(&state.pool)
.await?
.force_fullscreen
{
controlled_keys.insert("fullscreen".to_string());
}
Ok((metadata, document, input_bytes, controlled_keys))
}
fn build_editor_state(
metadata: &InstanceMetadata,
document: &GameOptionsDocument,
input_bytes: &[u8],
controlled_keys: &HashSet<String>,
) -> GameSettingsEditorState {
let game_version = metadata.applied_content_set.game_version.as_str();
let mut settings = Vec::new();
if supported_settings_cover_game_version(game_version) {
for definition in all_supported_settings() {
let Some(physical_key) =
observed_physical_key(definition, document, game_version)
else {
continue;
};
let Some(value) = document.value(&physical_key).and_then(|raw| {
decode_value_for_version(
definition,
&physical_key,
raw,
game_version,
)
}) else {
continue;
};
let controlled = controlled_keys.contains(definition.id);
settings.push(EditableGameSetting {
option_id: definition.id.to_string(),
category_id: definition.category.to_string(),
kind: GameOptionKind::Vanilla,
raw_key: None,
sync_enabled: true,
canonical_value: Some(value),
value_state: GameOptionValueState::Canonical,
option_revision: 0,
editor: editor_for(definition),
compatibility: compatibility(controlled),
validation_error: None,
controlled,
});
}
}
let mut custom_settings = document
.effective_entries()
.into_iter()
.filter_map(|(key, (index, _))| {
if controlled_keys.contains(key)
|| setting_by_file_key(key).is_some()
|| is_never_sync_key(key)
{
return None;
}
let entry = document.lines[index].entry.as_ref()?;
validate_raw_key_value(key, &entry.value).ok()?;
Some(EditableGameSetting {
option_id: custom_setting_id(key),
category_id: "custom".to_string(),
kind: GameOptionKind::External,
raw_key: Some(key.to_string()),
sync_enabled: true,
canonical_value: Some(CanonicalValue::ExternalRaw(
entry.value.clone(),
)),
value_state: GameOptionValueState::Canonical,
option_revision: 0,
editor: custom_setting_editor(),
compatibility: compatibility(false),
validation_error: None,
controlled: false,
})
})
.collect::<Vec<_>>();
custom_settings.sort_by(|left, right| left.raw_key.cmp(&right.raw_key));
settings.extend(custom_settings);
GameSettingsEditorState {
summary_revision: editor_revision(metadata, input_bytes),
canonical_revision: 0,
catalog_revision: CATALOG_REVISION,
total_participating: 1,
categories: categories(&settings),
settings,
}
}
fn encode_setting_value(
metadata: &InstanceMetadata,
document: &GameOptionsDocument,
setting: &EditableGameSetting,
value: &CanonicalValue,
) -> crate::Result<(String, String)> {
let game_version = metadata.applied_content_set.game_version.as_str();
if let Some(definition) = setting_by_id(&setting.option_id) {
validate_canonical_value(Some(definition), value)?;
let key = observed_physical_key(definition, document, game_version)
.ok_or_else(|| {
input_error(format!(
"{} is no longer present in options.txt",
setting.option_id
))
})?;
let raw = encode_value(
definition,
&key,
value,
game_version,
document.value(&key),
)
.ok_or_else(|| {
input_error(format!(
"{} cannot be represented by this Minecraft version",
setting.option_id
))
})?;
Ok((key, raw))
} else {
validate_canonical_value(None, value)?;
let key = setting.raw_key.clone().ok_or_else(|| {
input_error("Custom setting is missing its file key")
})?;
let CanonicalValue::ExternalRaw(raw) = value else {
return Err(input_error(
"Custom settings require a raw string value",
));
};
validate_raw_key_value(&key, raw)?;
Ok((key, raw.clone()))
}
}
async fn load_editor(
instance_id: &str,
state: &State,
) -> crate::Result<GameSettingsEditorState> {
let (metadata, document, input_bytes, controlled_keys) =
load_instance_document(instance_id, state).await?;
Ok(build_editor_state(
&metadata,
&document,
&input_bytes,
&controlled_keys,
))
}
pub async fn get_config(
instance_id: &str,
) -> crate::Result<GameSettingsEditorState> {
let state = State::get().await?;
let _guard = state.lock_synced_options().await;
load_editor(instance_id, &state).await
}
pub async fn preview_changes(
instance_id: &str,
request: UpdateGameSettingsRequest,
) -> crate::Result<GameSettingsEditorState> {
let state = State::get().await?;
let _guard = state.lock_synced_options().await;
let (metadata, document, input_bytes, controlled_keys) =
load_instance_document(instance_id, &state).await?;
let mut editor = build_editor_state(
&metadata,
&document,
&input_bytes,
&controlled_keys,
);
let stale = request.expected_catalog_revision != CATALOG_REVISION
|| request.expected_summary_revision != editor.summary_revision
|| request.expected_canonical_revision != editor.canonical_revision;
for change in request.changes {
let Some(setting) = editor
.settings
.iter_mut()
.find(|setting| setting.option_id == change.option_id)
else {
continue;
};
if stale || change.base_option_revision != setting.option_revision {
setting.validation_error =
Some(GameOptionValidationIssue::ChangedSinceOpened);
continue;
}
if change.sync_enabled.is_some() {
setting.validation_error =
Some(GameOptionValidationIssue::InvalidValue);
continue;
}
if let Some(value) = change.canonical_value {
match value {
Some(value)
if encode_setting_value(
&metadata, &document, setting, &value,
)
.is_ok() =>
{
setting.canonical_value = Some(value);
setting.value_state = GameOptionValueState::Canonical;
setting.validation_error = None;
}
_ => {
setting.canonical_value = None;
setting.value_state = GameOptionValueState::Invalid;
setting.validation_error =
Some(GameOptionValidationIssue::InvalidValue);
}
}
}
}
Ok(editor)
}
pub async fn save_changes(
instance_id: &str,
request: UpdateGameSettingsRequest,
) -> crate::Result<SaveGameSettingsResult> {
let state = State::get().await?;
let _guard = state.lock_synced_options().await;
let (metadata, mut document, input_bytes, controlled_keys) =
load_instance_document(instance_id, &state).await?;
let current_editor = build_editor_state(
&metadata,
&document,
&input_bytes,
&controlled_keys,
);
let UpdateGameSettingsRequest {
expected_summary_revision,
expected_canonical_revision,
expected_catalog_revision,
changes,
} = request;
if expected_catalog_revision != CATALOG_REVISION
|| expected_summary_revision != current_editor.summary_revision
|| expected_canonical_revision != current_editor.canonical_revision
{
return Ok(SaveGameSettingsResult {
state: Some(current_editor),
conflicts: changes
.into_iter()
.map(|change| change.option_id)
.collect(),
..SaveGameSettingsResult::default()
});
}
let mut changed = false;
for change in changes {
let setting = current_editor
.settings
.iter()
.find(|setting| setting.option_id == change.option_id)
.ok_or_else(|| {
input_error(format!(
"Unknown game setting {}",
change.option_id
))
})?;
if setting.controlled {
return Err(input_error(format!(
"{} is controlled by the instance's launcher settings",
setting.option_id
)));
}
if change.base_option_revision != setting.option_revision {
return Ok(SaveGameSettingsResult {
state: Some(current_editor),
conflicts: vec![change.option_id],
..SaveGameSettingsResult::default()
});
}
if change.sync_enabled.is_some() {
return Err(input_error(
"Sync selection cannot be changed in the instance editor",
));
}
let Some(Some(value)) = change.canonical_value else {
return Err(input_error(format!(
"Choose a value for {}",
setting.option_id
)));
};
let (key, raw) =
encode_setting_value(&metadata, &document, setting, &value)?;
changed |= document.set(&key, &raw, false)?;
}
if changed {
io::write(options_path(&metadata, &state)?, document.serialize()?)
.await?;
}
let refreshed = load_editor(instance_id, &state).await?;
Ok(SaveGameSettingsResult {
state: Some(refreshed),
applied: u32::from(changed),
..SaveGameSettingsResult::default()
})
}
@@ -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<String, String>;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct ArchiveIndex {
#[serde(default)]
pub version: u32,
pub bundles: Vec<LanguageBundle>,
}
#[derive(Default, Serialize, Deserialize)]
pub(super) struct LanguageBundle {
pub nested_path: String,
pub locales: BTreeMap<String, Translations>,
#[serde(default)]
pub deprecated: DeprecatedTranslations,
}
#[derive(Default, Serialize, Deserialize)]
pub(super) struct DeprecatedTranslations {
#[serde(default)]
removed: Vec<String>,
#[serde(default)]
renamed: BTreeMap<String, String>,
}
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<Translations> {
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::<BTreeMap<String, serde_json::Value>>(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<ArchiveIndex> {
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<R: Read + Seek>(
archive: &mut ZipArchive<R>,
name: &str,
limit: usize,
budget: &mut usize,
) -> crate::Result<Vec<u8>> {
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<R: Read + Seek>(
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<String> =
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::<serde_json::Value>(&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<u8>,
hash: &str,
) -> crate::Result<Vec<u8>> {
if sha1_bytes(&bytes) != hash {
return Err(input_error("Minecraft locale resource checksum mismatch"));
}
Ok(bytes)
}
@@ -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<String>,
archive: ArchiveSource,
nested_path: String,
translation_key: String,
choices: BTreeMap<String, String>,
}
#[derive(Default, Serialize)]
pub struct GameSettingLocaleLabels {
pub settings: BTreeMap<String, GameSettingLocaleLabel>,
}
#[derive(Serialize)]
pub struct GameSettingLocaleLabel {
pub label: String,
pub choices: BTreeMap<String, String>,
pub source: Option<GameSettingSource>,
}
#[derive(Serialize)]
pub struct GameSettingSource {
pub instance_id: String,
pub file_name: String,
pub file_path: String,
pub project: Option<GameSettingSourceProject>,
}
#[derive(Serialize)]
pub struct GameSettingSourceProject {
pub id: String,
pub title: String,
pub icon_url: Option<String>,
}
struct Candidate {
snapshot_id: String,
snapshot: Snapshot,
keys: BTreeMap<String, String>,
}
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<State>) {
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<String, String> {
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<String> {
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<String, Arc<ArchiveIndex>>,
) -> crate::Result<Arc<ArchiveIndex>> {
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<String> {
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<String>,
refresh_sources: bool,
) -> crate::Result<GameSettingLocaleLabels> {
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::<Vec<_>>()).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<String, Translations> = 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)
}
@@ -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<String, Arc<str>>;
struct SnapshotIndex {
snapshot: Snapshot,
keys: BTreeMap<String, (usize, Arc<str>)>,
}
#[derive(Default)]
pub(super) struct OriginResolver {
archives: HashMap<String, Arc<ArchiveKeys>>,
snapshots: HashMap<String, SnapshotIndex>,
}
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<Option<Origin>> {
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<ArchiveKeys> {
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::<ArchiveKeys>(&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<str> = 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)
}
@@ -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<ArchiveSource>,
}
pub(super) async fn mod_projects(
state: &State,
hashes: &[&str],
) -> crate::Result<HashMap<String, Project>> {
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::<Vec<_>>(),
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<T: Serialize>(
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<String> {
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<Snapshot> {
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<ArchiveIndex> {
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::<ArchiveIndex>(&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)
}
@@ -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<String>,
pub source_game_version: Option<String>,
pub backfilled: bool,
pub observation: Option<Observation>,
pub origin: Option<Origin>,
}
#[tracing::instrument(skip_all, fields(scope), err)]
pub(super) async fn load(
pool: &SqlitePool,
scope: Option<&str>,
) -> crate::Result<Vec<OriginRow>> {
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<String> = row.get("source_instance_id");
let source_game_version: Option<String> = row.get("source_game_version");
let backfilled: bool = row.get("backfilled");
let observation_json: Option<String> = row.get("observation_json");
let origin_json: Option<String> = 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(())
}
@@ -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<bool> {
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;
@@ -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<GameOptionsLine>,
}
#[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<GameOptionsEntry>,
}
#[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<Option<Self>> {
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<Self> {
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<bool> {
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<Vec<u8>> {
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<Vec<u8>> {
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<String>) -> 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<PathBuf> {
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<u8>)> {
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))
}
@@ -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<Option<(Vec<u8>, 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<bool> {
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<GameOptionsPackSource>,
) -> 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<Vec<u8>> = 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(())
}
@@ -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<bool> {
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(&current_key) {
continue;
}
let Some(current_raw) = document.value(&current_key) else {
continue;
};
let candidate = if let Some(definition) = definition {
decode_value_for_version(
definition,
&current_key,
current_raw,
&metadata.applied_content_set.game_version,
)
} else if matches!(stored.kind, GameOptionKind::External) {
if validate_raw_key_value(&current_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<String>,
state: &State,
) -> crate::Result<bool> {
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)
}
@@ -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<String>,
pub incompatible: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(in crate::api::instance) enum ResourcePackOptionsUpdate {
Deferred,
Applied(ResourcePackOptions),
}
fn parse_entries(raw: &str) -> crate::Result<Vec<String>> {
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<String>,
selected: &[String],
) -> Vec<String> {
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<Option<ResourcePackOptions>> {
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<String>,
selected: &[String],
state: &State,
) -> crate::Result<ResourcePackOptionsUpdate> {
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))
}
@@ -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<GameOptionValidationIssue> {
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<GameSettingsEditorState> {
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::<Vec<_>>();
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<GameSettingsEditorState> {
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<GameSettingsEditorState> {
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<SaveGameSettingsResult> {
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)
}
@@ -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<Vec<GameOptionsSourceCandidate>> {
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)
}
@@ -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<bool> {
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<AppliedSettings> {
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::<i32>().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<SyncOutcome> {
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<SyncOutcome> {
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
}
@@ -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<HotbarFamily> {
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<bool> {
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(&current);
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<NbtTag> {
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<NbtTag> {
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<NbtTag>,
) {
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<HotbarState> {
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<u8> = 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<bool> {
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)
}
@@ -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;
File diff suppressed because it is too large. Load diff
@@ -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<SyncedOptionJoinResolution>,
},
}
#[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<Vec<PendingChange>> {
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<bool> {
Ok(read(state).await?.iter().any(|change| {
change.instance_id == instance_id && change.option == option
}))
}
File diff suppressed because it is too large. Load diff
@@ -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<Vec<NbtCompound>> {
if !path.exists() {
return Ok(Vec::new());
}
servers_from_root(read_nbt_file(path).await?)
}
pub(super) fn servers_from_bytes(
bytes: Vec<u8>,
) -> crate::Result<Vec<NbtCompound>> {
servers_from_root(nbt_from_bytes(bytes)?)
}
fn servers_from_root(root: NbtCompound) -> crate::Result<Vec<NbtCompound>> {
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<Vec<u8>> {
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<bool>,
) -> 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<bool>,
) {
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()
}
@@ -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};
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff. Show more