Compare commits

..
Author SHA1 Message Date
AxTps eabac1f11e docs: 补充 1.0.2-beta5 公告
Sync source to CNB / Sync Git ref (push) Skipped
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
Prepare pnpm cache / prepare (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (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 / website (push) Canceled after 0s
Repository checks / tombi (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
Rust checks / shear (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
2026-10-11 07:06:54 +08:00
Ax_Tps b776b99f26 Merge pull request #12 from ApTx2012/feat/ui
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
feat: 接入皮肤站公告 API
2026-10-11 06:13:00 +08:00
Ax_Tps daeedc4951 Merge pull request #10 from ApTx2012/feat/launch-customization
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
feat: 支持自定义 Minecraft 窗口标题
2026-10-11 06:12:25 +08:00
AxTps cadb7bbdcd feat: 接入皮肤站公告 API
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:34:00 +08:00
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 / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (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 / website (push) Canceled after 0s
Rust checks / shear (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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (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
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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 / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Prepare pnpm cache / prepare (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 / website (push) Canceled after 0s
Rust checks / shear (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 / tombi (push) Canceled after 0s
Starlight release / Build (Windows x64) (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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (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 / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (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 / website (push) Canceled after 0s
Rust checks / shear (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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (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
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
Axolotl desktop CI / desktop (macos-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 / guardrails (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-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 / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (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
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 / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (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:36:53 +08:00
AxTps f98dbf1592 fix: migration guard 豁免补建表引发的删除与分歧
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-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
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Axolotl desktop CI / website (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 / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Starlight release / Release preflight (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 / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (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
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 / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (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:27:16 +08:00
AxTps d422f3aeaf docs: 补充 1.0.2-beta1 更新日志(周年庆彩蛋)
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-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
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-04 11:26:45 +08:00
AxTps a21fe3750b feat: 新增 Starlight 周年庆彩蛋(每年 10 月 4 日)
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (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 / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (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 / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (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
AxTps 2f34faa50f fix: 1.0.1 更新公告改到正确的 catalog.ts(网站 catalog.json 回滚)
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (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 / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-03 14:01:35 +08:00
AxTps a57dfb828e docs: 添加 1.0.1 正式版更新公告
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-03 13:49:45 +08:00
Ap_Tx eec6b47ee7 Merge pull request #6 from ApTx2012/feat/axolotl-port
Feat/axolotl port
2026-10-03 12:55:16 +08:00
AxTps f776bf8995 fix: 修复跨 crate 可见性与 Version 导入,补充 semver 依赖
Sync source to CNB / Sync Git ref (push) Has been cancelled
- updater_impl.rs: Version 改从 semver 导入(tauri_plugin_updater::Version 是私有)

- files.rs: 移除未使用的 list 导入

- state/mod.rs: update_http_client_for_settings 由 pub(crate) 改 pub(apps/app 跨 crate 调用)

- workspace 与 apps/app 补充 semver 依赖,同步 Cargo.lock
2026-10-03 11:20:33 +08:00
AxTps 69a00888ba ci: 给 changelog-comment 的 job 显式声明 permissions
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 11:03:59 +08:00
AxTps 996044bdc2 fix: 接入备份维护钩子并补齐 SQLX 缓存
Sync source to CNB / Sync Git ref (push) Has been cancelled
- ProcessManager 新增 has_instance_process(备份启动守卫依赖)

- 删除/启动/编辑/迁移目录时接入备份维护钩子(lock_instance_maintenance + begin_instance_deletion + delete_instance_backups),remove_instance 拆出 remove_instance_files_and_state

- run 启动前检查活跃备份操作并加维护锁

- 目录迁移时搬移默认备份仓库并入队清理

- 更新 settings SELECT 的 SQLX 离线缓存(补 ui_font/backup_repository_path/mono_font)

- create_recorded_symlink 非 Windows 未用参数加 cfg_attr 豁免
2026-10-03 10:59:16 +08:00
AxTps 4674f31dd3 Merge remote-tracking branch 'origin/main' into feat/axolotl-port
Sync source to CNB / Sync Git ref (push) Has been cancelled
# Conflicts:
#	apps/app/src/updater_impl.rs
2026-10-03 10:21:43 +08:00
AxTps fe61337a34 style: 运行 cargo fmt 修正格式(备份命令/更新比较/事件与设置文件的换行与缩进)
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 10:18:53 +08:00
AxTps b633f7cdf0 fix: 修复 beta9 无法自动更新到 beta10——自定义版本比较器按数字解析 prerelease
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 10:11:49 +08:00
AxTps 8df1ac8a86 fix: 修复 beta9 无法自动更新到 beta10——自定义版本比较器按数字解析 prerelease
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-10-03 10:08:43 +08:00
AxTps a99a0a6e30 feat(backup): 实例备份第5轮——新增备份设置/排除选择器/仓库设置组件,接入实例设置与存储设置页,补齐中英繁文案
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 09:43:57 +08:00
AxTps 773cf387a3 feat(backup): 实例备份第4轮——新增前端 helper(instance-backup.ts)及单元测试 2026-10-03 09:37:41 +08:00
AxTps 7414477bbd feat(backup): 实例备份第3轮——注册 15 个 Tauri 备份命令并补齐 ACL 权限 2026-10-03 09:32:46 +08:00
AxTps e8f10ab1ef feat(backup): 实例备份第2轮——移植 backup.rs 核心实现并接入 instance 模块导出 2026-10-03 09:19:28 +08:00
AxTps e91ba2ae74 feat(backup): 实例备份第1轮——独立备份库迁移、settings 仓库路径字段、备份进度事件 2026-10-03 09:12:35 +08:00
AxTps 95a3e00b03 feat(fonts): 外观设置新增界面/等宽字体选择器,含中英繁中文案
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 23:04:12 +08:00
AxTps f490600ee2 feat(fonts): 前端字体工具函数、主题字体状态与设置类型 2026-10-02 21:57:24 +08:00
AxTps b57219f348 feat(fonts): 后端字体命令 fonts_get_system_fonts 及 ui_font/mono_font 设置字段 2026-10-02 21:33:13 +08:00
AxTps ead942abb2 feat: 移植忽略 SSL 证书错误选项(设置开关 + 重建 HTTP 客户端)
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-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
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 21:20:26 +08:00
AxTps d2c32ed14f style: 修正 feature_flags 反序列化处的 rustfmt 格式,修复 release preflight 失败
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-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
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 20:47:19 +08:00
AxTps 4b9b9ebf47 docs: 添加 1.0.1-beta10 更新公告
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-02 20:42:12 +08:00
AxTps 457dfd8c30 refactor: App.vue 整包写回改为始终基于最新设置,彻底消除旧快照覆盖隐患
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
2026-10-02 20:38:31 +08:00
AxTps 8ec5cf104c chore: 补充实例页导览四步的 en-US 文案
Axolotl desktop CI / desktop (macos-latest) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
2026-10-02 20:29:12 +08:00
AxTps 306328367e feat: 重做实例页导览为内容/文件/世界/日志四步,并修复 inspect 步点导览气泡无法继续的问题 2026-10-02 20:24:20 +08:00
AxTps 3901749d93 fix: 皮肤站内嵌 iframe 显式委派 clipboard-write/read 权限,修复无法复制 2026-10-02 20:06:24 +08:00
AxTps cee0c3bbd4 fix: 批量编辑分组失败时改用 handleError 提示,不再静默吞掉异常 2026-10-02 20:04:07 +08:00
AxTps 63ca20db04 fix: edit_to_core 仅在相关字段变更时才带 content_set/launch_overrides patch,修复无 content set 的实例改分组失败 2026-10-02 20:00:49 +08:00
AxTps 0388e339db feat: 安装位置选到盘符根时自动追加 Starlight 子目录,避免文件散落根目录
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (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 / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
2026-10-02 19:44:45 +08:00
AxTps d4554b0f4c fix: onboarding/locale 整包写回前用 themeStore 回填 feature_flags,避免功能开关被旧快照覆盖 2026-10-02 19:27:57 +08:00
AxTps d5471bb36c fix: feature_flags 反序列化忽略未知开关,避免前后端枚举不同步导致启动崩溃 2026-10-02 19:16:45 +08:00
AxTps f238eeae0e fix: 界面缩放持久化被 onboarding/locale 整包写回覆盖,保存前用最新 uiScale 回填 2026-10-02 19:02:48 +08:00
326 changed files with 36014 additions and 300 deletions

No files matched your search

+6 -1
View File
@@ -12,13 +12,18 @@ on:
permissions:
issues: write
pull-requests: read
pull-requests: write
jobs:
comment:
name: Post changelog comment
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
contents: read
steps:
- name: Post or update changelog comment
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
+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
+50 -2
View File
@@ -1889,6 +1889,28 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -3477,6 +3499,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.9.1"
@@ -5102,6 +5133,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rsa"
version = "0.9.10"
@@ -5758,6 +5795,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.15.2"
@@ -6795,7 +6841,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "1.0.0-beta15"
version = "1.0.2-beta4"
dependencies = [
"ariadne",
"async-minecraft-ping",
@@ -6899,7 +6945,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "1.0.0-beta15"
version = "1.0.2-beta4"
dependencies = [
"ashpd",
"async_zip",
@@ -6912,6 +6958,7 @@ dependencies = [
"either",
"enumset",
"flate2",
"fontdb",
"futures",
"hyper",
"hyper-util",
@@ -6926,6 +6973,7 @@ dependencies = [
"path-util",
"quartz_nbt",
"regex",
"semver",
"serde",
"serde_json",
"serde_with",
+2
View File
@@ -44,6 +44,7 @@ encoding_rs = "0.8.35"
enumset = "1.1.10"
eyre = "0.6.12"
flate2 = "1.1.4"
fontdb = "0.24.0"
fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.1"
@@ -83,6 +84,7 @@ rgb = "0.8.52"
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8.4"
rustls-pki-types = "1.15.0"
semver = "1.0.28"
serde = "1.0.228"
serde_bytes = "0.11.19"
serde_cbor = "0.11.2"
+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",
+5 -1
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'
@@ -1260,7 +1262,7 @@ async function replayOnboarding(mode) {
async function finishOnboarding() {
const wasReplay = onboardingReplay.value
const settings = onboardingSettings.value ?? (await getSettings())
const settings = await getSettings()
if (!onboardingReplay.value) {
if (onboardingMode.value === 'instance') {
settings.onboarding_instance_tour_completed = true
@@ -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,207 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.2-beta5',
version: '1.0.2-beta5',
publishedAt: '2026-10-11',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta5',
'zh-CN': 'Starlight Launcher 1.0.2-beta5',
},
changes: {
added: [
{
'en-US': 'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.',
'zh-CN': '新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。',
},
{
'en-US': 'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.',
'zh-CN': '新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。',
},
],
fixed: [
{
'en-US': 'Completed the Chinese translations for the armor preview feature.',
'zh-CN': '补齐盔甲预览功能的中文翻译。',
},
],
},
},
{
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',
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',
@@ -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,420 @@
<script setup lang="ts">
import {
Button,
commonMessages,
defineMessages,
NewModal,
useModalStack,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import {
announcementKey,
isAnnouncementActive,
mapBroadcastAnnouncements,
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
parseAnnouncements,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
type RemoteAnnouncement,
safeAnnouncementUrl,
} from '@/helpers/remote-announcements'
import {
requestSkinSiteDownloadToken,
skinSiteStatus,
waitForSkinSiteSession,
} from '@/composables/skin-site-session.ts'
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 = 'starlight-remote-announcements-cache-v1'
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
}
}
function saveCache(next: RemoteAnnouncement[]) {
if (!cacheKey) return
try {
localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), items: next }))
} catch {
// localStorage may be unavailable (private mode / quota)
}
}
async function refresh() {
if (disposed) return
loadCache()
if (skinSiteStatus.value !== 'signed-in') return
try {
await waitForSkinSiteSession()
const token = await requestSkinSiteDownloadToken()
const response = await tauriFetch('https://skin.starlight.cool/starlight/skin/broadcast/all', {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) return
const data = await response.json()
const parsed = mapBroadcastAnnouncements((data as { payload?: unknown } | null)?.payload)
if (parsed && !disposed) {
sync(parsed, true)
saveCache(parsed)
}
} catch {
// Remote fetch failed; previously cached items remain displayed
}
}
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>
@@ -16,6 +16,7 @@ const messages = defineMessages({
<template>
<iframe
:src="skinSiteFrameUrl"
allow="clipboard-write; clipboard-read"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
/>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { FileIcon, FolderIcon, MinusIcon, PlusIcon } from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
import {
type BackupExclusion,
type BackupExclusionKind,
canonicalizeBackupExclusions,
formatBackupExclusionPath,
normalizeBackupExclusion,
} from '@/helpers/instance-backup'
const props = defineProps<{
instanceId: string
instanceRoot: string
modelValue: BackupExclusion[]
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: BackupExclusion[]] }>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const picking = ref<BackupExclusionKind | null>(null)
const messages = defineMessages({
empty: {
id: 'instance.backups.exclusions.empty',
defaultMessage: 'No files or folders are excluded.',
},
addFile: {
id: 'instance.backups.exclusions.add-file',
defaultMessage: 'Add excluded file',
},
addFolder: {
id: 'instance.backups.exclusions.add-folder',
defaultMessage: 'Add excluded folder',
},
remove: {
id: 'instance.backups.exclusions.remove',
defaultMessage: 'Remove {path} from exclusions',
},
})
function pickedPath(result: unknown): string | null {
if (typeof result === 'string') return result
if (result && typeof result === 'object' && 'path' in result) {
const path = (result as { path?: unknown }).path
return typeof path === 'string' ? path : null
}
return null
}
async function addExclusion(kind: BackupExclusionKind) {
if (props.disabled || picking.value) return
picking.value = kind
try {
const result = await open({
directory: kind === 'directory',
multiple: false,
defaultPath: props.instanceRoot,
})
const selectedPath = pickedPath(result)
if (!selectedPath) return
const exclusion = await normalizeBackupExclusion(props.instanceId, selectedPath, kind)
emit('update:modelValue', canonicalizeBackupExclusions([...props.modelValue, exclusion]))
} catch (error) {
handleError(error)
} finally {
picking.value = null
}
}
function removeExclusion(path: string) {
emit(
'update:modelValue',
props.modelValue.filter((exclusion) => exclusion.path !== path),
)
}
</script>
<template>
<div class="flex flex-col gap-3">
<p v-if="modelValue.length === 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.empty) }}
</p>
<div
v-else
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
>
<div
v-for="exclusion in modelValue"
:key="exclusion.path"
class="flex min-h-11 items-center gap-3 px-3 py-2"
>
<FolderIcon
v-if="exclusion.kind === 'directory'"
class="size-5 shrink-0 text-[var(--color-text-tertiary)]"
/>
<FileIcon v-else class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
<code class="min-w-0 flex-1 truncate text-sm text-[var(--color-text-primary)]">{{
formatBackupExclusionPath(exclusion)
}}</code>
<Button
type="quiet"
size="2xs"
circular
icon-only
:disabled="disabled || picking !== null"
:aria-label="
formatMessage(messages.remove, { path: formatBackupExclusionPath(exclusion) })
"
@click="removeExclusion(exclusion.path)"
><MinusIcon />
</Button>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button type="outlined" :disabled="disabled || picking !== null" @click="addExclusion('file')"
><PlusIcon />
{{ formatMessage(messages.addFile) }}
</Button>
<Button
type="outlined"
:disabled="disabled || picking !== null"
@click="addExclusion('directory')"
><PlusIcon />
{{ formatMessage(messages.addFolder) }}
</Button>
</div>
</div>
</template>
@@ -0,0 +1,675 @@
<script setup lang="ts">
import { FileArchiveIcon, SaveIcon, TrashIcon, UndoIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useFormatBytes,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { get_full_path } from '@/helpers/instance'
import {
type BackupExclusion,
type BackupOperation,
type BackupOperationState,
type BackupProgressStage,
type BackupRestorePreview,
type BackupSnapshot,
cancelBackup,
deleteBackup,
disableBackups,
enableBackups,
getBackupConfig,
type InstanceBackupConfig,
type InstanceBackupEligibility,
listBackupOperations,
listBackups,
listenBackupProgress,
restoreBackup,
startBackup,
startBackupRestorePreview,
updateBackupExclusions,
} from '@/helpers/instance-backup'
import { injectInstanceSettings } from '@/providers/instance-settings'
import BackupExclusionSelector from './BackupExclusionSelector.vue'
const { instance } = injectInstanceSettings()
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'instance.backups.title', defaultMessage: 'Instance backups' },
description: {
id: 'instance.backups.description',
defaultMessage: 'Create deduplicated snapshots of the whole instance while it is closed.',
},
loading: { id: 'instance.backups.loading', defaultMessage: 'Loading backup settings...' },
unavailable: { id: 'instance.backups.unavailable', defaultMessage: 'Backups are unavailable' },
notInstalled: {
id: 'instance.backups.unavailable.not-installed',
defaultMessage: 'Finish installing this instance before enabling backups.',
},
symlinkInstance: {
id: 'instance.backups.unavailable.symlink',
defaultMessage: 'Imported symbolic-link instances cannot be backed up.',
},
directLinked: {
id: 'instance.backups.unavailable.direct-linked',
defaultMessage: 'Instances linked directly to another launcher cannot be backed up.',
},
externalDirectory: {
id: 'instance.backups.unavailable.external-directory',
defaultMessage: 'Instances that use an external game directory cannot be backed up.',
},
rootMissing: {
id: 'instance.backups.unavailable.root-missing',
defaultMessage: 'The instance folder is missing.',
},
rootLink: {
id: 'instance.backups.unavailable.root-link',
defaultMessage: 'Instances whose root folder is a link cannot be backed up.',
},
choose: {
id: 'instance.backups.choose',
defaultMessage:
'Everything is backed up by default. Add files or folders that should be excluded.',
},
enable: { id: 'instance.backups.enable', defaultMessage: 'Enable backups' },
editExclusions: { id: 'instance.backups.edit-exclusions', defaultMessage: 'Edit exclusions' },
saveExclusions: {
id: 'instance.backups.save-exclusions',
defaultMessage: 'Save exclusions',
},
allIncluded: {
id: 'instance.backups.all-included',
defaultMessage: 'All instance files and folders are included.',
},
excludedPaths: {
id: 'instance.backups.excluded-paths',
defaultMessage: 'Excluded: {paths}',
},
backupNow: { id: 'instance.backups.backup-now', defaultMessage: 'Back up now' },
cancelBackup: { id: 'instance.backups.cancel', defaultMessage: 'Cancel backup' },
stageScanning: {
id: 'instance.backups.stage.scanning',
defaultMessage: 'Scanning files',
},
stageHashing: { id: 'instance.backups.stage.hashing', defaultMessage: 'Hashing files' },
stageSaving: { id: 'instance.backups.stage.saving', defaultMessage: 'Saving snapshot index' },
stageQueued: { id: 'instance.backups.stage.queued', defaultMessage: 'Waiting to start' },
stageValidating: {
id: 'instance.backups.stage.validating',
defaultMessage: 'Validating restore',
},
stageMaterializing: {
id: 'instance.backups.stage.materializing',
defaultMessage: 'Preparing changed files',
},
stageApplying: {
id: 'instance.backups.stage.applying',
defaultMessage: 'Applying changed files',
},
stagePreview: {
id: 'instance.backups.stage.preview',
defaultMessage: 'Analyzing restore changes',
},
progressBytes: {
id: 'instance.backups.progress-bytes',
defaultMessage: '{processed} of {total}',
},
stageFailed: { id: 'instance.backups.stage.failed', defaultMessage: 'Backup failed' },
stageInterrupted: {
id: 'instance.backups.stage.interrupted',
defaultMessage: 'Backup operation was interrupted',
},
snapshots: { id: 'instance.backups.snapshots', defaultMessage: 'Snapshots' },
empty: { id: 'instance.backups.empty', defaultMessage: 'No backups have been created yet.' },
snapshotMeta: {
id: 'instance.backups.snapshot-meta',
defaultMessage: '{files, plural, one {# file} other {# files}} · {size}',
},
addedSize: { id: 'instance.backups.added-size', defaultMessage: '{size} newly stored' },
restore: { id: 'instance.backups.restore', defaultMessage: 'Restore backup' },
delete: { id: 'instance.backups.delete', defaultMessage: 'Delete backup' },
disable: { id: 'instance.backups.disable', defaultMessage: 'Disable backups' },
disableBlocked: {
id: 'instance.backups.disable-blocked',
defaultMessage: 'Delete every snapshot before disabling backups.',
},
restoreTitle: { id: 'instance.backups.restore-title', defaultMessage: 'Restore this backup?' },
restoreBody: {
id: 'instance.backups.restore-body',
defaultMessage:
'Upcoming operations: add {added, plural, one {# file} other {# files}}, modify {modified, plural, one {# file} other {# files}}, and delete {deleted, plural, one {# file} other {# files}}. Continue?',
},
deleteTitle: { id: 'instance.backups.delete-title', defaultMessage: 'Delete this backup?' },
deleteBody: {
id: 'instance.backups.delete-body',
defaultMessage:
'This snapshot will be permanently deleted. Unused stored files will also be removed.',
},
})
const config = ref<InstanceBackupConfig | null>(null)
const snapshots = ref<BackupSnapshot[]>([])
const instanceRoot = ref('')
const loading = ref(true)
const editingExclusions = ref(false)
const excludedPaths = ref<BackupExclusion[]>([])
const action = ref<string | null>(null)
const operation = ref<BackupOperation | null>(null)
const selectedSnapshot = ref<BackupSnapshot | null>(null)
const restorePreview = ref<BackupRestorePreview | null>(null)
const deleteModal = ref<InstanceType<typeof NewModal>>()
const restoreModal = ref<InstanceType<typeof NewModal>>()
let unlisten: (() => void) | null = null
const eligible = computed(() => config.value?.eligibility === 'eligible')
const terminalStates: BackupOperationState[] = ['completed', 'cancelled', 'failed', 'interrupted']
const isRunning = computed(
() => operation.value !== null && !terminalStates.includes(operation.value.state),
)
const canCancel = computed(() => operation.value?.cancellable === true)
const operationLabel = computed(() => {
if (operation.value?.operation_type === 'restore_preview') {
return formatMessage(messages.stagePreview)
}
switch (operation.value?.state) {
case 'queued':
return formatMessage(messages.stageQueued)
case 'hashing':
return formatMessage(messages.stageHashing)
case 'saving':
return formatMessage(messages.stageSaving)
case 'validating':
return formatMessage(messages.stageValidating)
case 'materializing':
return formatMessage(messages.stageMaterializing)
case 'applying':
return formatMessage(messages.stageApplying)
default:
return formatMessage(messages.stageScanning)
}
})
function operationFromEvent(event: {
operationId: string
operationType: BackupOperation['operation_type']
instanceId?: string
stage: BackupProgressStage
processedBytes: number
totalBytes: number
snapshotId?: string
message?: string
}): BackupOperation {
const now = Date.now()
const state: BackupOperationState =
event.stage === 'restoring' || event.stage === 'copying'
? 'materializing'
: event.stage === 'deleting'
? 'queued'
: event.stage
return {
id: event.operationId,
operation_type: event.operationType,
instance_id: event.instanceId ?? instance.value.id,
snapshot_id: event.snapshotId,
state,
processed_bytes: event.processedBytes,
total_bytes: event.totalBytes,
cancellable: event.stage === 'scanning' || event.stage === 'hashing',
cancel_requested: false,
error: event.message,
created_at: operation.value?.id === event.operationId ? operation.value.created_at : now,
updated_at: now,
finished_at: ['completed', 'cancelled', 'failed'].includes(event.stage) ? now : undefined,
}
}
async function refreshOperation() {
const operations = await listBackupOperations(instance.value.id)
const latest = operations[0] ?? null
operation.value =
latest && (isRunningState(latest.state) || ['failed', 'interrupted'].includes(latest.state))
? latest
: null
if (operation.value?.operation_type === 'restore_preview' && operation.value.snapshot_id) {
selectedSnapshot.value =
snapshots.value.find((snapshot) => snapshot.id === operation.value?.snapshot_id) ?? null
}
}
function isRunningState(state: BackupOperationState) {
return !terminalStates.includes(state)
}
const eligibilityMessages: Record<
Exclude<InstanceBackupEligibility, 'eligible'>,
keyof typeof messages
> = {
not_installed: 'notInstalled',
symlink_instance: 'symlinkInstance',
direct_linked_instance: 'directLinked',
external_game_directory: 'externalDirectory',
root_missing: 'rootMissing',
root_is_link: 'rootLink',
}
const unavailableReason = computed(() => {
const eligibility = config.value?.eligibility
if (!eligibility || eligibility === 'eligible') return ''
return formatMessage(messages[eligibilityMessages[eligibility]])
})
async function refresh() {
loading.value = true
try {
const nextConfig = await getBackupConfig(instance.value.id)
config.value = nextConfig
excludedPaths.value = nextConfig.enabled ? [...nextConfig.excluded_paths] : []
const [nextRoot, nextSnapshots] = await Promise.all([
nextConfig.eligibility === 'eligible' ? get_full_path(instance.value.id) : '',
listBackups(instance.value.id),
])
instanceRoot.value = nextRoot
snapshots.value = nextSnapshots
await refreshOperation()
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
async function runAction(name: string, task: () => Promise<void>) {
action.value = name
try {
await task()
} catch (error) {
handleError(error)
} finally {
action.value = null
}
}
function enable() {
void runAction('enable', async () => {
config.value = await enableBackups(instance.value.id, excludedPaths.value)
editingExclusions.value = false
})
}
function beginEditingExclusions() {
excludedPaths.value = [...(config.value?.excluded_paths ?? [])]
editingExclusions.value = true
}
function saveExclusions() {
void runAction('exclusions', async () => {
config.value = await updateBackupExclusions(instance.value.id, excludedPaths.value)
editingExclusions.value = false
})
}
function start() {
void runAction('start', async () => {
const id = await startBackup(instance.value.id)
operation.value = {
id,
operation_type: 'create',
instance_id: instance.value.id,
state: 'queued',
processed_bytes: 0,
total_bytes: 0,
cancellable: true,
cancel_requested: false,
created_at: Date.now(),
updated_at: Date.now(),
}
})
}
function cancel() {
if (!operation.value || !canCancel.value) return
void runAction('cancel', async () => {
if (await cancelBackup(operation.value!.id)) {
operation.value = {
...operation.value!,
cancellable: false,
cancel_requested: true,
}
}
})
}
function confirmDelete(snapshot: BackupSnapshot) {
selectedSnapshot.value = snapshot
deleteModal.value?.show()
}
function confirmRestore(snapshot: BackupSnapshot) {
void runAction(`preview:${snapshot.id}`, async () => {
selectedSnapshot.value = snapshot
const id = await startBackupRestorePreview(snapshot.id)
operation.value = {
id,
operation_type: 'restore_preview',
instance_id: instance.value.id,
snapshot_id: snapshot.id,
state: 'queued',
processed_bytes: 0,
total_bytes: snapshot.logical_size,
cancellable: true,
cancel_requested: false,
created_at: Date.now(),
updated_at: Date.now(),
}
})
}
function removeSnapshot() {
if (!selectedSnapshot.value) return
void runAction(`delete:${selectedSnapshot.value.id}`, async () => {
await deleteBackup(selectedSnapshot.value!.id)
await refresh()
deleteModal.value?.hide()
selectedSnapshot.value = null
})
}
function restoreSnapshot() {
if (!selectedSnapshot.value || !restorePreview.value) return
void runAction(`restore:${selectedSnapshot.value.id}`, async () => {
const id = await restoreBackup(selectedSnapshot.value!.id, restorePreview.value!.plan_token)
operation.value = {
id,
operation_type: 'restore',
instance_id: instance.value.id,
snapshot_id: selectedSnapshot.value!.id,
state: 'queued',
processed_bytes: 0,
total_bytes: selectedSnapshot.value!.logical_size,
cancellable: true,
cancel_requested: false,
created_at: Date.now(),
updated_at: Date.now(),
}
restoreModal.value?.hide()
selectedSnapshot.value = null
restorePreview.value = null
})
}
function disable() {
void runAction('disable', async () => {
await disableBackups(instance.value.id)
await refresh()
})
}
onMounted(async () => {
unlisten = await listenBackupProgress(async (event) => {
if (
!['create', 'restore', 'restore_preview'].includes(event.operationType) ||
event.instanceId !== instance.value.id
)
return
operation.value = operationFromEvent(event)
if (event.operationType === 'restore_preview' && event.stage === 'completed') {
const completed = (await listBackupOperations(instance.value.id)).find(
(candidate) => candidate.id === event.operationId,
)
const snapshot = snapshots.value.find((candidate) => candidate.id === event.snapshotId)
if (completed?.restore_preview && snapshot) {
selectedSnapshot.value = snapshot
restorePreview.value = completed.restore_preview
operation.value = null
restoreModal.value?.show()
return
}
}
if (['completed', 'cancelled', 'failed'].includes(event.stage)) {
void refresh()
}
})
await refresh()
})
onUnmounted(() => unlisten?.())
watch(
() => instance.value.id,
() => void refresh(),
)
</script>
<template>
<div class="flex flex-col gap-5">
<header>
<h2 class="m-0 text-lg font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 mt-1 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.description) }}
</p>
</header>
<p v-if="loading" class="m-0 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.loading) }}
</p>
<Admonition v-else-if="!eligible" type="warning" :header="formatMessage(messages.unavailable)">
{{ unavailableReason }}
</Admonition>
<template v-else-if="config">
<div v-if="!config.enabled || editingExclusions" class="flex flex-col gap-4">
<p class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.choose) }}</p>
<BackupExclusionSelector
v-model="excludedPaths"
:instance-id="instance.id"
:instance-root="instanceRoot"
:disabled="action !== null"
/>
<div class="flex flex-wrap gap-2">
<Button :disabled="action !== null" @click="config.enabled ? saveExclusions() : enable()"
><SaveIcon />
{{ formatMessage(config.enabled ? messages.saveExclusions : messages.enable) }}
</Button>
<Button
v-if="config.enabled"
type="outlined"
:disabled="action !== null"
@click="editingExclusions = false"
><XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
</div>
<template v-else>
<div class="flex flex-col gap-2 rounded-lg border border-solid border-surface-4 p-3">
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{
config.excluded_paths.length === 0
? formatMessage(messages.allIncluded)
: formatMessage(messages.excludedPaths, {
paths: config.excluded_paths.map((entry) => entry.path).join(', '),
})
}}
</p>
<div class="flex flex-wrap gap-2">
<Button :disabled="isRunning || action !== null" @click="start"
><FileArchiveIcon />
{{ formatMessage(messages.backupNow) }}
</Button>
<Button v-if="canCancel" type="outlined" :disabled="action !== null" @click="cancel"
><XIcon />
{{ formatMessage(messages.cancelBackup) }}
</Button>
<Button
type="outlined"
:disabled="isRunning || action !== null"
@click="beginEditingExclusions"
>{{ formatMessage(messages.editExclusions) }}
</Button>
</div>
<p v-if="isRunning" class="m-0 text-sm font-medium text-[var(--color-text-primary)]">
{{ operationLabel }}
<span
v-if="(operation?.total_bytes ?? 0) > 0"
class="ml-2 text-[var(--color-text-tertiary)]"
>
{{
formatMessage(messages.progressBytes, {
processed: formatBytes(operation?.processed_bytes ?? 0),
total: formatBytes(operation?.total_bytes ?? 0),
})
}}
</span>
</p>
<p
v-else-if="operation?.state === 'failed' || operation?.state === 'interrupted'"
class="m-0 text-sm text-red"
>
{{
formatMessage(
operation.state === 'interrupted'
? messages.stageInterrupted
: messages.stageFailed,
)
}}<span v-if="operation.error">: {{ operation.error }}</span>
</p>
</div>
<section class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.snapshots) }}
</h3>
<p v-if="snapshots.length === 0" class="m-0 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.empty) }}
</p>
<div
v-else
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
>
<div
v-for="snapshot in snapshots"
:key="snapshot.id"
class="flex items-center gap-3 px-3 py-3"
>
<FileArchiveIcon class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
<div class="min-w-0 flex-1">
<p class="m-0 font-semibold text-[var(--color-text-primary)]">
{{ formatDate(new Date(snapshot.created_at)) }}
</p>
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{
formatMessage(messages.snapshotMeta, {
files: snapshot.file_count,
size: formatBytes(snapshot.logical_size),
})
}}
·
{{
formatMessage(messages.addedSize, { size: formatBytes(snapshot.added_size) })
}}
</p>
</div>
<Button
type="quiet"
size="2xs"
circular
icon-only
:disabled="action !== null || isRunning"
:aria-label="formatMessage(messages.restore)"
@click="confirmRestore(snapshot)"
><UndoIcon />
</Button>
<Button
type="quiet"
color="red"
size="2xs"
circular
icon-only
:disabled="action !== null || isRunning"
:aria-label="formatMessage(messages.delete)"
@click="confirmDelete(snapshot)"
><TrashIcon />
</Button>
</div>
</div>
</section>
<div class="flex flex-col items-start gap-2">
<Button
type="outlined"
color="red"
:disabled="snapshots.length > 0 || action !== null || isRunning"
@click="disable"
>{{ formatMessage(messages.disable) }}
</Button>
<p v-if="snapshots.length > 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.disableBlocked) }}
</p>
</div>
</template>
</template>
<NewModal
ref="deleteModal"
:header="formatMessage(messages.deleteTitle)"
fade="danger"
max-width="500px"
>
<Admonition type="critical">{{ formatMessage(messages.deleteBody) }}</Admonition>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button
type="outlined"
:disabled="action?.startsWith('delete:')"
@click="deleteModal?.hide()"
>{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="red"
:disabled="action?.startsWith('delete:')"
@click="removeSnapshot"
><TrashIcon />{{ formatMessage(messages.delete) }}
</Button>
</div>
</template>
</NewModal>
<NewModal ref="restoreModal" :header="formatMessage(messages.restoreTitle)" max-width="500px">
<Admonition v-if="restorePreview" type="warning">
{{
formatMessage(messages.restoreBody, {
added: restorePreview.added_files,
modified: restorePreview.modified_files,
deleted: restorePreview.deleted_files,
})
}}
</Admonition>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button
type="outlined"
:disabled="action?.startsWith('restore:')"
@click="restoreModal?.hide()"
>{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button :disabled="action?.startsWith('restore:')" @click="restoreSnapshot"
><UndoIcon />{{ formatMessage(messages.restore) }}
</Button>
</div>
</template>
</NewModal>
</div>
</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>
@@ -61,6 +61,7 @@ import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
RadioButtons,
StyledInput,
@@ -72,6 +73,7 @@ import { edit, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const props = defineProps<{
instanceIds: string[]
@@ -148,7 +150,7 @@ async function deleteGroup(group: string) {
for (const instance of allInstances.value) {
if (instance.groups.includes(group)) {
const newGroups = instance.groups.filter((g) => g !== group)
await edit(instance.id, { groups: newGroups }).catch(() => {})
await edit(instance.id, { groups: newGroups }).catch(handleError)
instance.groups = newGroups
}
}
@@ -167,7 +169,7 @@ async function confirm() {
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
for (const instanceId of props.instanceIds) {
await edit(instanceId, { groups }).catch(() => {})
await edit(instanceId, { groups }).catch(handleError)
}
emit('applied')
@@ -7,6 +7,7 @@ import {
FileArchiveIcon,
InfoIcon,
MonitorIcon,
RefreshCwIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
@@ -20,8 +21,10 @@ import type { PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
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'
@@ -52,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({
@@ -105,6 +109,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: WrenchIcon,
content: InstallationSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.backups',
defaultMessage: 'Backups',
}),
icon: FileArchiveIcon,
content: BackupSettings,
},
{
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
name: defineMessage({
@@ -138,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() {
@@ -35,7 +35,7 @@ const progressWidth = computed(() => `${Math.min(100, (props.current / props.tot
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
</div>
<ButtonStyled circular type="transparent">
<button :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
<button data-onboarding-skip :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
<XIcon />
</button>
</ButtonStyled>
@@ -252,6 +252,42 @@ export const onboardingMessages = defineMessages({
id: 'app.onboarding.instance-tabs.description',
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
},
instanceContentTabTitle: {
id: 'app.onboarding.instance-content-tab.title',
defaultMessage: 'Content',
},
instanceContentTabDescription: {
id: 'app.onboarding.instance-content-tab.description',
defaultMessage:
'Manage this instance’s mods, resource packs, and shaders here. Search, enable or disable entries, check for updates, and browse for more.',
},
instanceFilesTabTitle: {
id: 'app.onboarding.instance-files-tab.title',
defaultMessage: 'Files',
},
instanceFilesTabDescription: {
id: 'app.onboarding.instance-files-tab.description',
defaultMessage:
'Browse the instance folder and open configs or logs in the built-in editor without leaving the launcher.',
},
instanceWorldsTabTitle: {
id: 'app.onboarding.instance-worlds-tab.title',
defaultMessage: 'Worlds',
},
instanceWorldsTabDescription: {
id: 'app.onboarding.instance-worlds-tab.description',
defaultMessage:
'Manage saved worlds: back them up, rename, edit world settings, and open the world folder.',
},
instanceLogsTabTitle: {
id: 'app.onboarding.instance-logs-tab.title',
defaultMessage: 'Logs',
},
instanceLogsTabDescription: {
id: 'app.onboarding.instance-logs-tab.description',
defaultMessage:
'Read the instance’s live logs to trace crashes and errors without digging through files.',
},
labTitle: {
id: 'app.onboarding.lab.title',
defaultMessage: 'Useful tools, built in',
@@ -509,15 +545,29 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.instanceActionsTitle,
onboardingMessages.instanceActionsDescription,
),
step(
'instance-tabs',
'inspect',
copy(
onboardingMessages.instanceTabsTitle,
onboardingMessages.instanceTabsDescription,
onboardingMessages.finishArea,
),
{ targetId: 'instance-tabs' },
inspect(
'instance-content-tab',
'instance-tab-content',
onboardingMessages.instanceContentTabTitle,
onboardingMessages.instanceContentTabDescription,
),
inspect(
'instance-files-tab',
'instance-tab-files',
onboardingMessages.instanceFilesTabTitle,
onboardingMessages.instanceFilesTabDescription,
),
inspect(
'instance-worlds-tab',
'instance-tab-worlds',
onboardingMessages.instanceWorldsTabTitle,
onboardingMessages.instanceWorldsTabDescription,
),
inspect(
'instance-logs-tab',
'instance-tab-logs',
onboardingMessages.instanceLogsTabTitle,
onboardingMessages.instanceLogsTabDescription,
),
],
}
@@ -279,8 +279,11 @@ export function useOnboardingTour(
function handleDocumentClick(event: MouseEvent) {
if (!visible.value) return
const clickedElement = event.target instanceof Element ? event.target : null
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
// 跳过/关闭按钮由其自身处理,不在全局点击里推进
if (clickedElement?.closest('[data-onboarding-skip]')) return
// inspect 步:点击任意位置(含导览气泡本身)都继续下一步
if (step.value.interaction === 'inspect') {
event.preventDefault()
event.stopImmediatePropagation()
@@ -288,6 +291,9 @@ export function useOnboardingTour(
return
}
// 非 inspect 步:点在导览 UI 自身时不处理
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
if (clickedElement && handleBranchClick(clickedElement)) return
if (!targetElement.value?.contains(event.target as Node)) return
@@ -22,7 +22,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { appDataDir, join } from '@tauri-apps/api/path'
import { open } from '@tauri-apps/plugin-dialog'
import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs'
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
@@ -41,6 +41,19 @@ import {
parseCustomAccentColor,
} from '@/store/theme.ts'
import {
buildFontFamilyOptions,
canonicalFontFamily,
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
type FontFamilyOption,
type FontFamilyOptionOrDivider,
optionValueToFontSetting,
resolveFontFamily,
type SystemFontFamily,
} from '@/helpers/font-family.ts'
import { getSystemFontFamilies } from '@/helpers/system-fonts.ts'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
@@ -363,6 +376,63 @@ const messages = defineMessages({
defaultMessage:
'Hide the downloads button in the sidebar when there are no active download tasks.',
},
fontsTitle: { id: 'app.appearance-settings.fonts.title', defaultMessage: 'Fonts' },
fontsDescription: {
id: 'app.appearance-settings.fonts.description',
defaultMessage: 'Choose the fonts the launcher interface and its logs are rendered in.',
},
uiFontTitle: { id: 'app.appearance-settings.fonts.ui-font', defaultMessage: 'Interface font' },
uiFontDescription: {
id: 'app.appearance-settings.fonts.ui-font-description',
defaultMessage:
'Used across the launcher interface. Only fonts installed on this system are listed.',
},
monoFontTitle: {
id: 'app.appearance-settings.fonts.mono-font',
defaultMessage: 'Monospace font',
},
monoFontDescription: {
id: 'app.appearance-settings.fonts.mono-font-description',
defaultMessage: 'Used for logs, the server console, and other monospace content.',
},
fontDefault: {
id: 'app.appearance-settings.fonts.default',
defaultMessage: 'Launcher default',
},
fontMonospace: {
id: 'app.appearance-settings.fonts.monospace',
defaultMessage: 'Monospace',
},
fontMissing: {
id: 'app.appearance-settings.fonts.missing',
defaultMessage: 'Not installed',
},
fontSearchPlaceholder: {
id: 'app.appearance-settings.fonts.search-placeholder',
defaultMessage: 'Search installed fonts',
},
fontNoResults: {
id: 'app.appearance-settings.fonts.no-results',
defaultMessage: 'No matching fonts',
},
fontLoadFailed: {
id: 'app.appearance-settings.fonts.load-failed',
defaultMessage: "Couldn't read the list of installed fonts.",
},
fontRetry: { id: 'app.appearance-settings.fonts.retry', defaultMessage: 'Retry' },
fontReset: {
id: 'app.appearance-settings.fonts.reset',
defaultMessage: 'Reset',
},
fontPreviewLabel: { id: 'app.appearance-settings.fonts.preview', defaultMessage: 'Preview' },
fontUiPreviewSample: {
id: 'app.appearance-settings.fonts.ui-preview-sample',
defaultMessage: 'The quick brown fox jumps over the lazy dog.',
},
fontMonoPreviewSample: {
id: 'app.appearance-settings.fonts.mono-preview-sample',
defaultMessage: 'const x = () => 0; // monospace',
},
})
const os = ref(await getOS())
@@ -373,6 +443,78 @@ const customBackgroundPreview = computed(() =>
: null,
)
const systemFonts = ref<SystemFontFamily[]>([])
const systemFontsLoading = ref(true)
const systemFontsFailed = ref(false)
async function loadSystemFonts() {
systemFontsLoading.value = true
systemFontsFailed.value = false
try {
systemFonts.value = await getSystemFontFamilies()
} catch (error) {
systemFontsFailed.value = true
console.warn('Failed to read the list of installed fonts', error)
} finally {
systemFontsLoading.value = false
}
}
function selectedOptionLabel(options: FontFamilyOptionOrDivider[], value: string): string {
const option = options.find(
(entry): entry is FontFamilyOption => 'value' in entry && entry.value === value,
)
return option?.label ?? ''
}
const uiFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
buildFontFamilyOptions(systemFonts.value, {
defaultLabel: formatMessage(messages.fontDefault),
missingLabel: formatMessage(messages.fontMissing),
selected: settings.value.ui_font,
}),
)
const monoFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
buildFontFamilyOptions(systemFonts.value, {
defaultLabel: formatMessage(messages.fontDefault),
missingLabel: formatMessage(messages.fontMissing),
monospaceLabel: formatMessage(messages.fontMonospace),
selected: settings.value.mono_font,
groupMonospaced: true,
}),
)
const uiFontSelection = computed({
get: () => canonicalFontFamily(systemFonts.value, settings.value.ui_font),
set: (value: string) => {
settings.value.ui_font = optionValueToFontSetting(value)
},
})
const monoFontSelection = computed({
get: () => canonicalFontFamily(systemFonts.value, settings.value.mono_font),
set: (value: string) => {
settings.value.mono_font = optionValueToFontSetting(value)
},
})
const uiFontSearchValue = computed(() =>
selectedOptionLabel(uiFontOptions.value, uiFontSelection.value),
)
const monoFontSearchValue = computed(() =>
selectedOptionLabel(monoFontOptions.value, monoFontSelection.value),
)
const uiFontPreview = computed(() =>
resolveFontFamily(settings.value.ui_font, DEFAULT_UI_FONT_STACK),
)
const monoFontPreview = computed(() =>
resolveFontFamily(settings.value.mono_font, DEFAULT_MONO_FONT_STACK),
)
function updateUiScale(value: number) {
const clamped = Math.min(200, Math.max(50, Math.round(value)))
setUiScale(clamped)
@@ -489,6 +631,8 @@ watch(
settings.value.transparent_background_blur,
settings.value.sidebar_instance_count,
settings.value.close_behavior,
settings.value.ui_font,
settings.value.mono_font,
] as const,
([
path,
@@ -499,6 +643,8 @@ watch(
transparentBlur,
sidebarInstanceCount,
closeBehavior,
uiFont,
monoFont,
]) => {
themeStore.customBackgroundPath = path
themeStore.customBackgroundBlur = blur
@@ -509,10 +655,19 @@ watch(
themeStore.setTransparentBackgroundClass()
themeStore.sidebarInstanceCount = sidebarInstanceCount
themeStore.closeBehavior = closeBehavior as CloseBehavior
themeStore.uiFont = uiFont
themeStore.monoFont = monoFont
themeStore.setUiFont()
themeStore.setMonoFont()
},
{ immediate: true },
)
onMounted(() => {
if (props.scope === 'interface') {
void loadSystemFonts()
}
})
watch(
settings,
async () => {
@@ -1148,6 +1303,118 @@ watch(
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'interface'">
<template #header>
<h2
id="settings-target-appearance-fonts"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.fontsTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.fontsDescription) }}
</p>
</template>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-ui-font" tabindex="-1">
{{ formatMessage(messages.uiFontTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.uiFontDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Combobox
v-model="uiFontSelection"
:options="uiFontOptions"
searchable
:search-value="uiFontSearchValue"
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
:no-options-message="formatMessage(messages.fontNoResults)"
show-no-options-when-empty
:disabled="systemFontsLoading"
/>
</div>
<Button
type="quiet"
:disabled="settings.ui_font === null"
@click="settings.ui_font = null"
>
{{ formatMessage(messages.fontReset) }}
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">
{{ formatMessage(messages.fontPreviewLabel) }}
</span>
<div
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-base text-contrast"
:style="{ fontFamily: uiFontPreview }"
>
{{ formatMessage(messages.fontUiPreviewSample) }}
</div>
</div>
</div>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-mono-font" tabindex="-1">
{{ formatMessage(messages.monoFontTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.monoFontDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Combobox
v-model="monoFontSelection"
:options="monoFontOptions"
searchable
:search-value="monoFontSearchValue"
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
:no-options-message="formatMessage(messages.fontNoResults)"
show-no-options-when-empty
:disabled="systemFontsLoading"
/>
</div>
<Button
type="quiet"
:disabled="settings.mono_font === null"
@click="settings.mono_font = null"
>
{{ formatMessage(messages.fontReset) }}
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">
{{ formatMessage(messages.fontPreviewLabel) }}
</span>
<div
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-xs text-contrast"
:style="{ fontFamily: monoFontPreview }"
>
{{ formatMessage(messages.fontMonoPreviewSample) }}
</div>
</div>
</div>
</template>
</SettingsRow>
<div
v-if="systemFontsFailed"
class="flex items-center justify-between gap-3 px-4 pb-4 text-sm text-secondary"
>
<span>{{ formatMessage(messages.fontLoadFailed) }}</span>
<Button type="quiet" @click="loadSystemFonts">
{{ formatMessage(messages.fontRetry) }}
</Button>
</div>
</SettingsSection>
<SettingsSection v-if="props.scope === 'content-downloads'">
<SettingsRow>
<template #label>
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { FolderIcon } from '@modrinth/assets'
import {
Admonition,
Button,
defineMessages,
injectFilePicker,
injectNotificationManager,
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import {
type BackupRepositoryStatus,
getBackupRepositoryStatus,
listBackupOperations,
listenBackupProgress,
moveBackupRepository,
} from '@/helpers/instance-backup'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const filePicker = injectFilePicker()
const { handleError } = injectNotificationManager()
const status = ref<BackupRepositoryStatus | null>(null)
const loading = ref(true)
const moving = ref(false)
const movedBytes = ref(0)
const moveTotalBytes = ref(0)
const movingOperationId = ref<string | null>(null)
let unlisten: (() => void) | null = null
const messages = defineMessages({
title: { id: 'settings.backups.repository.title', defaultMessage: 'Backup repository' },
description: {
id: 'settings.backups.repository.description',
defaultMessage: 'Deduplicated instance backup data and its independent index database.',
},
location: { id: 'settings.backups.repository.location', defaultMessage: 'Location' },
locationDescription: {
id: 'settings.backups.repository.location-description',
defaultMessage:
'Changing the location moves the complete repository. Choose a new or empty folder.',
},
change: { id: 'settings.backups.repository.change', defaultMessage: 'Change folder' },
moving: { id: 'settings.backups.repository.moving', defaultMessage: 'Moving repository...' },
movingProgress: {
id: 'settings.backups.repository.moving-progress',
defaultMessage: 'Moving repository: {processed} of {total}',
},
usage: { id: 'settings.backups.repository.usage', defaultMessage: 'Storage usage' },
usageDescription: {
id: 'settings.backups.repository.usage-description',
defaultMessage:
'{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}.',
},
unavailable: {
id: 'settings.backups.repository.unavailable',
defaultMessage: 'Repository unavailable',
},
})
async function refresh() {
loading.value = true
try {
status.value = await getBackupRepositoryStatus()
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
async function changeLocation() {
const selection = await filePicker.pickFolder?.()
if (!selection) return
moving.value = true
movedBytes.value = 0
moveTotalBytes.value = 0
try {
movingOperationId.value = await moveBackupRepository(selection.path)
} catch (error) {
handleError(error)
moving.value = false
}
}
onMounted(async () => {
unlisten = await listenBackupProgress((event) => {
if (event.operationType !== 'repository_move') return
if (movingOperationId.value && event.operationId !== movingOperationId.value) return
movingOperationId.value = event.operationId
moving.value = !event.finalState
movedBytes.value = event.processedBytes
moveTotalBytes.value = event.totalBytes
if (event.finalState) void refresh()
})
const activeMove = (await listBackupOperations(undefined, true)).find(
(operation) => operation.operation_type === 'repository_move',
)
if (activeMove) {
movingOperationId.value = activeMove.id
moving.value = true
movedBytes.value = activeMove.processed_bytes
moveTotalBytes.value = activeMove.total_bytes
}
await refresh()
})
onUnmounted(() => unlisten?.())
</script>
<template>
<SettingsSection
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<SettingsRow>
<template #label>{{ formatMessage(messages.location) }}</template>
<template #description>
<span class="break-all">{{ status?.path ?? '...' }}</span>
<span class="mt-1 block">{{ formatMessage(messages.locationDescription) }}</span>
</template>
<template #control>
<Button type="outlined" :disabled="loading || moving" @click="changeLocation"
><FolderIcon />
{{
formatMessage(
moving && moveTotalBytes > 0
? messages.movingProgress
: moving
? messages.moving
: messages.change,
{
processed: formatBytes(movedBytes),
total: formatBytes(moveTotalBytes),
},
)
}}
</Button>
</template>
</SettingsRow>
<SettingsRow v-if="status">
<template #label>{{ formatMessage(messages.usage) }}</template>
<template #description>
{{
formatMessage(messages.usageDescription, {
stored: formatBytes(status.stored_size),
logical: formatBytes(status.logical_size),
count: status.snapshot_count,
})
}}
</template>
</SettingsRow>
<Admonition
v-if="status && !status.available"
type="critical"
:header="formatMessage(messages.unavailable)"
>
{{ status.error }}
</Admonition>
</SettingsSection>
</template>
@@ -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())
@@ -253,6 +253,15 @@ const messages = defineMessages({
defaultMessage:
'When CurseForge does not provide a download address, derive its CDN address and try downloading the file automatically. Disable this to use the manual download workflow.',
},
ignoreSslErrors: {
id: 'app.settings.resources.ignore-ssl-errors',
defaultMessage: 'Ignore SSL certificate errors',
},
ignoreSslErrorsDescription: {
id: 'app.settings.resources.ignore-ssl-errors-description',
defaultMessage:
'Skip TLS certificate validation for launcher network requests. Only enable this if you trust your network and understand the security risk.',
},
mojangAuthService: {
id: 'app.settings.resources.mojang-auth-service',
defaultMessage: 'Mojang authentication service',
@@ -923,6 +932,15 @@ function validateMinecraftDirectory(value) {
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.ignoreSslErrors) }}</template>
<template #description>
{{ formatMessage(messages.ignoreSslErrorsDescription) }}
</template>
<template #control>
<Toggle id="ignore-ssl-errors" v-model="settings.ignore_ssl_errors" />
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'content-downloads'">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import BackupRepositorySettings from './BackupRepositorySettings.vue'
import ResourceManagementSettings from './ResourceManagementSettings.vue'
import StorageSettings from './StorageSettings.vue'
</script>
@@ -6,6 +7,7 @@ import StorageSettings from './StorageSettings.vue'
<template>
<div class="flex flex-col gap-6">
<ResourceManagementSettings scope="storage-backups" />
<BackupRepositorySettings />
<StorageSettings />
</div>
</template>
@@ -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,179 @@
/**
* Font family plumbing shared by the appearance settings pickers and the theme
* store. Kept free of imports so `node --test` can load it directly.
*/
/** Mirrors `--font-standard` in `packages/assets/styles/defaults.scss`. */
export const DEFAULT_UI_FONT_STACK =
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif'
/** Mirrors `--mono-font` in `packages/assets/styles/defaults.scss`. */
export const DEFAULT_MONO_FONT_STACK =
'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
/** Picker value for "follow the launcher default"; persisted as `null`. */
export const FOLLOW_DEFAULT_FONT = ''
export interface SystemFontFamily {
family: string
monospaced: boolean
}
export interface FontFamilyOption {
value: string
label: string
subLabel?: string
searchTerms?: string[]
}
export type FontFamilyOptionOrDivider = FontFamilyOption | { type: 'divider' }
export interface FontFamilyOptionsConfig {
defaultLabel: string
selected?: string | null
missingLabel?: string
monospaceLabel?: string
groupMonospaced?: boolean
}
const MAX_FONT_FAMILY_LENGTH = 128
/**
* Quotes and escapes an installed family name so it cannot break out of the
* font-family declaration it is spliced into. Returns null when the name has
* nothing usable left.
*/
export function cssFontFamilyName(name: string): string | null {
const trimmed = name.replace(/\p{Cc}/gu, '').trim()
if (!trimmed || trimmed.length > MAX_FONT_FAMILY_LENGTH) {
return null
}
return `"${trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* The value written into `--font-standard` / `--mono-font`. The launcher stack
* always trails the chosen family so an unresolvable name degrades to the
* previous default instead of the user agent's own font.
*/
export function resolveFontFamily(
setting: string | null | undefined,
fallbackStack: string,
): string {
if (!setting) {
return fallbackStack
}
const family = cssFontFamilyName(setting)
return family ? `${family}, ${fallbackStack}` : fallbackStack
}
export function fontSettingToOptionValue(setting: string | null | undefined): string {
return setting ?? FOLLOW_DEFAULT_FONT
}
export function optionValueToFontSetting(value: string): string | null {
const trimmed = value.trim()
return trimmed === FOLLOW_DEFAULT_FONT ? null : trimmed
}
function compareFamilies(first: SystemFontFamily, second: SystemFontFamily): number {
const left = first.family.toLowerCase()
const right = second.family.toLowerCase()
if (left === right) {
return 0
}
return left < right ? -1 : 1
}
function toOption(family: SystemFontFamily, monospaceLabel?: string): FontFamilyOption {
return {
value: family.family,
label: family.family,
...(family.monospaced && monospaceLabel
? { subLabel: monospaceLabel, searchTerms: [monospaceLabel] }
: {}),
}
}
/**
* The spelling the font list uses for a stored family. Font names are matched
* case-insensitively so a stored value that only differs in case still selects
* its option instead of leaving the picker blank.
*/
export function canonicalFontFamily(
fonts: SystemFontFamily[],
setting: string | null | undefined,
): string {
const selected = optionValueToFontSetting(setting ?? '')
if (!selected) {
return FOLLOW_DEFAULT_FONT
}
const listed = fonts.find((font) => font.family.trim().toLowerCase() === selected.toLowerCase())
return listed ? listed.family.trim() : selected
}
/**
* Builds the picker options for the installed font collection: the default
* entry first, the current selection re-added when it is no longer installed,
* and — for the monospace picker — monospaced families ahead of the rest.
*/
export function buildFontFamilyOptions(
fonts: SystemFontFamily[],
config: FontFamilyOptionsConfig,
): FontFamilyOptionOrDivider[] {
const families = new Map<string, SystemFontFamily>()
for (const font of fonts) {
const family = font.family.trim()
if (!family) {
continue
}
const key = family.toLowerCase()
const known = families.get(key)
if (known) {
known.monospaced ||= font.monospaced
} else {
families.set(key, { family, monospaced: font.monospaced })
}
}
const sorted = [...families.values()].sort(compareFamilies)
const selected = optionValueToFontSetting(config.selected ?? '')
const options: FontFamilyOptionOrDivider[] = [
{ value: FOLLOW_DEFAULT_FONT, label: config.defaultLabel },
]
if (selected && !families.has(selected.toLowerCase()) && config.missingLabel) {
options.push({ value: selected, label: selected, subLabel: config.missingLabel })
}
if (config.groupMonospaced) {
const monospaced = sorted.filter((font) => font.monospaced)
const proportional = sorted.filter((font) => !font.monospaced)
options.push(...monospaced.map((font) => toOption(font, config.monospaceLabel)))
if (monospaced.length > 0 && proportional.length > 0) {
options.push({ type: 'divider' })
}
options.push(...proportional.map((font) => toOption(font, config.monospaceLabel)))
return options
}
options.push(...sorted.map((font) => toOption(font)))
return options
}
@@ -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> {
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { canonicalizeBackupExclusions, formatBackupExclusionPath } from './instance-backup.ts'
test('directory exclusions display with a trailing slash', () => {
assert.equal(formatBackupExclusionPath({ path: 'caches', kind: 'directory' }), 'caches/')
assert.equal(
formatBackupExclusionPath({ path: 'config/somemod/bigdb.sqlite', kind: 'file' }),
'config/somemod/bigdb.sqlite',
)
})
test('directory exclusions cover descendants while file exclusions stay exact', () => {
assert.deepEqual(
canonicalizeBackupExclusions([
{ path: 'saves/world', kind: 'directory' },
{ path: 'config\\bigdb.sqlite', kind: 'file' },
{ path: 'saves', kind: 'directory' },
{ path: 'config', kind: 'file' },
]),
[
{ path: 'config', kind: 'file' },
{ path: 'saves', kind: 'directory' },
{ path: 'config/bigdb.sqlite', kind: 'file' },
],
)
})
test('directory wins when the same exclusion is selected as both kinds', () => {
assert.deepEqual(
canonicalizeBackupExclusions([
{ path: 'cache', kind: 'file' },
{ path: 'cache', kind: 'directory' },
]),
[{ path: 'cache', kind: 'directory' }],
)
})
@@ -0,0 +1,237 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
export type InstanceBackupEligibility =
| 'eligible'
| 'not_installed'
| 'symlink_instance'
| 'direct_linked_instance'
| 'external_game_directory'
| 'root_missing'
| 'root_is_link'
export interface BackupRepositoryStatus {
path: string
initialized: boolean
available: boolean
stored_size: number
logical_size: number
snapshot_count: number
error: string | null
}
export type BackupExclusionKind = 'file' | 'directory'
export interface BackupExclusion {
path: string
kind: BackupExclusionKind
}
export function formatBackupExclusionPath(exclusion: BackupExclusion): string {
return exclusion.kind === 'directory' ? `${exclusion.path}/` : exclusion.path
}
export interface InstanceBackupConfig {
instance_id: string
enabled: boolean
eligibility: InstanceBackupEligibility
excluded_paths: BackupExclusion[]
snapshot_count: number
}
export interface BackupSnapshot {
id: string
instance_id: string
instance_name: string
created_at: number
file_count: number
symlink_count: number
logical_size: number
added_size: number
}
export interface BackupDeleteSummary {
snapshot_count: number
logical_size: number
}
export interface BackupRestorePreview {
added_files: number
modified_files: number
deleted_files: number
plan_token: string
}
export type BackupOperationType =
'create' | 'restore' | 'restore_preview' | 'delete' | 'repository_move'
export type BackupOperationState =
| 'queued'
| 'scanning'
| 'hashing'
| 'saving'
| 'validating'
| 'materializing'
| 'applying'
| 'completed'
| 'cancelled'
| 'failed'
| 'interrupted'
export interface BackupOperation {
id: string
operation_type: BackupOperationType
instance_id: string
snapshot_id?: string
state: BackupOperationState
processed_bytes: number
total_bytes: number
cancellable: boolean
cancel_requested: boolean
error?: string
created_at: number
updated_at: number
finished_at?: number
restore_preview?: BackupRestorePreview
}
export type BackupProgressStage =
| 'scanning'
| 'hashing'
| 'saving'
| 'validating'
| 'copying'
| 'restoring'
| 'deleting'
| 'completed'
| 'cancelled'
| 'failed'
export type BackupOperationFinalState = 'completed' | 'cancelled' | 'failed'
export interface BackupProgressEvent {
operationId: string
instanceId?: string
operationType: BackupOperationType
stage: BackupProgressStage
processedBytes: number
totalBytes: number
finalState?: BackupOperationFinalState
snapshotId?: string
message?: string
}
export function canonicalizeBackupExclusions(values: BackupExclusion[]): BackupExclusion[] {
const normalized = values
.map((value) => ({ ...value, path: value.path.replaceAll('\\', '/') }))
.sort(
(left, right) =>
left.path.split('/').length - right.path.split('/').length ||
left.path.localeCompare(right.path),
)
const result: BackupExclusion[] = []
for (const value of normalized) {
const duplicate = result.find((existing) => existing.path === value.path)
if (duplicate) {
if (value.kind === 'directory') duplicate.kind = 'directory'
continue
}
if (
result.some(
(parent) => parent.kind === 'directory' && value.path.startsWith(`${parent.path}/`),
)
) {
continue
}
result.push(value)
}
return result
}
export function getBackupRepositoryStatus(): Promise<BackupRepositoryStatus> {
return invoke('plugin:instance|instance_get_backup_repository_status')
}
export function moveBackupRepository(destination: string): Promise<string> {
return invoke('plugin:instance|instance_move_backup_repository', { destination })
}
export function getBackupConfig(instanceId: string): Promise<InstanceBackupConfig> {
return invoke('plugin:instance|instance_get_backup_config', { instanceId })
}
export function normalizeBackupExclusion(
instanceId: string,
selectedPath: string,
kind: BackupExclusionKind,
): Promise<BackupExclusion> {
return invoke('plugin:instance|instance_normalize_backup_exclusion', {
instanceId,
selectedPath,
kind,
})
}
export function enableBackups(
instanceId: string,
excludedPaths: BackupExclusion[],
): Promise<InstanceBackupConfig> {
return invoke('plugin:instance|instance_enable_backups', { instanceId, excludedPaths })
}
export function updateBackupExclusions(
instanceId: string,
excludedPaths: BackupExclusion[],
): Promise<InstanceBackupConfig> {
return invoke('plugin:instance|instance_update_backup_exclusions', {
instanceId,
excludedPaths,
})
}
export function disableBackups(instanceId: string): Promise<void> {
return invoke('plugin:instance|instance_disable_backups', { instanceId })
}
export function startBackup(instanceId: string): Promise<string> {
return invoke('plugin:instance|instance_start_backup', { instanceId })
}
export function cancelBackup(operationId: string): Promise<boolean> {
return invoke('plugin:instance|instance_cancel_backup', { operationId })
}
export function listBackupOperations(
instanceId?: string,
activeOnly = false,
): Promise<BackupOperation[]> {
return invoke('plugin:instance|instance_list_backup_operations', {
instanceId,
activeOnly,
})
}
export function listBackups(instanceId: string): Promise<BackupSnapshot[]> {
return invoke('plugin:instance|instance_list_backups', { instanceId })
}
export function deleteBackup(snapshotId: string): Promise<void> {
return invoke('plugin:instance|instance_delete_backup', { snapshotId })
}
export function startBackupRestorePreview(snapshotId: string): Promise<string> {
return invoke('plugin:instance|instance_get_backup_restore_preview', { snapshotId })
}
export function restoreBackup(snapshotId: string, planToken: string): Promise<string> {
return invoke('plugin:instance|instance_restore_backup', { snapshotId, planToken })
}
export function getBackupDeleteSummary(instanceId: string): Promise<BackupDeleteSummary> {
return invoke('plugin:instance|instance_get_backup_delete_summary', { instanceId })
}
export function listenBackupProgress(
handler: (event: BackupProgressEvent) => void,
): Promise<UnlistenFn> {
return listen<BackupProgressEvent>('instance_backup_progress', (event) => handler(event.payload))
}
+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,
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { mapBroadcastAnnouncements } from './remote-announcements.ts'
test('maps a broadcast entry into a remote announcement', () => {
const result = mapBroadcastAnnouncements([
{
id: 1,
title: '维护通知',
subtitle: '今晚停机',
content: '服务器将于今晚维护。',
updateAt: 1791640873710,
star: true,
importantLevel: 9,
source: 'SKIN',
},
])
assert.equal(result?.length, 1)
const item = result?.[0]
assert.equal(item?.id, '1')
assert.equal(item?.title, '维护通知')
assert.equal(item?.summary, '今晚停机')
assert.equal(item?.priority, 'critical')
assert.equal(item?.pinned, true)
assert.equal(item?.type, 'modal')
assert.equal(item?.published_at, new Date(1791640873710).toISOString())
assert.equal(item?.ends_at, null)
})
test('empty or blank subtitle becomes null summary', () => {
const result = mapBroadcastAnnouncements([
{ id: 2, title: '公告', subtitle: ' ', content: '内容', updateAt: 1000 },
])
assert.equal(result?.[0]?.summary, null)
})
test('skips entries missing required fields', () => {
const result = mapBroadcastAnnouncements([
{ id: 3, title: '有效', content: '内容', updateAt: 1000 },
{ id: 4, title: '', content: '内容', updateAt: 1000 },
{ title: '无 id', content: '内容', updateAt: 1000 },
{ id: 5, title: '无时间', content: '内容' },
null,
'不是对象',
])
assert.equal(result?.length, 1)
assert.equal(result?.[0]?.id, '3')
})
test('clamps importantLevel and maps priority boundaries', () => {
const result = mapBroadcastAnnouncements([
{ id: 6, title: 'a', content: 'x', updateAt: 1000, importantLevel: 0 },
{ id: 7, title: 'b', content: 'x', updateAt: 1000, importantLevel: 3 },
{ id: 8, title: 'c', content: 'x', updateAt: 1000, importantLevel: 6 },
{ id: 9, title: 'd', content: 'x', updateAt: 1000, importantLevel: 999 },
])
const byId = new Map(result?.map((item) => [item.id, item.priority]))
assert.equal(byId.get('6'), 'low')
assert.equal(byId.get('7'), 'normal')
assert.equal(byId.get('8'), 'high')
assert.equal(byId.get('9'), 'critical')
})
test('returns null for non-array payloads', () => {
assert.equal(mapBroadcastAnnouncements(null), null)
assert.equal(mapBroadcastAnnouncements({ payload: [] }), null)
assert.equal(mapBroadcastAnnouncements('nope'), null)
})
test('pinned announcements sort first', () => {
const result = mapBroadcastAnnouncements([
{ id: 10, title: '普通', content: 'x', updateAt: 2000, importantLevel: 9 },
{ id: 11, title: '置顶', content: 'x', updateAt: 1000, star: true },
])
assert.equal(result?.[0]?.id, '11')
assert.equal(result?.[1]?.id, '10')
})
@@ -5,6 +5,7 @@ export type RemoteAnnouncement = {
content: string
type: 'modal' | 'notification'
priority: 'low' | 'normal' | 'high' | 'critical'
pinned?: boolean
starts_at: string
ends_at: string | null
published_at: string
@@ -32,18 +33,28 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null
if (!entry || typeof entry !== 'object') continue
const item = entry as Record<string, unknown>
if (
typeof item.id !== 'string' || item.id.length > 100 || ids.has(item.id) ||
typeof item.title !== 'string' || !item.title.trim() || item.title.length > 120 ||
typeof item.content !== 'string' || item.content.length > 20000 ||
typeof item.id !== 'string' ||
item.id.length > 100 ||
ids.has(item.id) ||
typeof item.title !== 'string' ||
!item.title.trim() ||
item.title.length > 120 ||
typeof item.content !== 'string' ||
item.content.length > 20000 ||
(item.summary != null && (typeof item.summary !== 'string' || item.summary.length > 300)) ||
!['modal', 'notification'].includes(String(item.type)) ||
!['low', 'normal', 'high', 'critical'].includes(String(item.priority)) ||
typeof item.starts_at !== 'string' || !Number.isFinite(Date.parse(item.starts_at)) ||
typeof item.published_at !== 'string' || !Number.isFinite(Date.parse(item.published_at)) ||
(item.ends_at != null && (typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) ||
(item.action_label != null && (typeof item.action_label !== 'string' || item.action_label.length > 80)) ||
typeof item.starts_at !== 'string' ||
!Number.isFinite(Date.parse(item.starts_at)) ||
typeof item.published_at !== 'string' ||
!Number.isFinite(Date.parse(item.published_at)) ||
(item.ends_at != null &&
(typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) ||
(item.action_label != null &&
(typeof item.action_label !== 'string' || item.action_label.length > 80)) ||
(item.action_url != null && !safeAnnouncementUrl(item.action_url))
) continue
)
continue
ids.add(item.id)
items.push({
...item,
@@ -54,7 +65,12 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null
} as RemoteAnnouncement)
}
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
return items.sort((first, second) => rank[second.priority] - rank[first.priority] || Date.parse(second.published_at) - Date.parse(first.published_at))
return items.sort(
(first, second) =>
Number(second.pinned ?? false) - Number(first.pinned ?? false) ||
rank[second.priority] - rank[first.priority] ||
Date.parse(second.published_at) - Date.parse(first.published_at),
)
}
export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now()) {
@@ -64,3 +80,76 @@ 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'
type BroadcastAnnouncementEntry = {
title?: unknown
subtitle?: unknown
content?: unknown
updateAt?: unknown
star?: unknown
importantLevel?: unknown
id?: unknown
}
function broadcastPriority(level: number): RemoteAnnouncement['priority'] {
if (level >= 9) return 'critical'
if (level >= 6) return 'high'
if (level >= 3) return 'normal'
return 'low'
}
export function mapBroadcastAnnouncements(payload: unknown): RemoteAnnouncement[] | null {
if (!Array.isArray(payload) || payload.length > 200) return null
const items: RemoteAnnouncement[] = []
const seen = new Set<string>()
for (const entry of payload) {
if (!entry || typeof entry !== 'object') continue
const item = entry as BroadcastAnnouncementEntry
if (
typeof item.id !== 'number' ||
!Number.isFinite(item.id) ||
typeof item.title !== 'string' ||
!item.title.trim() ||
item.title.length > 120 ||
typeof item.content !== 'string' ||
item.content.length > 20000 ||
typeof item.updateAt !== 'number' ||
!Number.isFinite(item.updateAt) ||
(item.subtitle != null && typeof item.subtitle !== 'string')
)
continue
const id = String(item.id)
if (seen.has(id)) continue
seen.add(id)
const level =
typeof item.importantLevel === 'number' && Number.isFinite(item.importantLevel)
? Math.max(0, Math.min(10, Math.floor(item.importantLevel)))
: 0
const starred = item.star === true
const published = new Date(item.updateAt).toISOString()
const summary = typeof item.subtitle === 'string' && item.subtitle.trim() ? item.subtitle : null
items.push({
id,
title: item.title,
summary,
content: item.content,
type: starred || level >= 8 ? 'modal' : 'notification',
priority: broadcastPriority(level),
pinned: starred,
starts_at: published,
ends_at: null,
published_at: published,
action_url: null,
action_label: null,
})
}
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
return items.sort(
(first, second) =>
Number(second.pinned ?? false) - Number(first.pinned ?? false) ||
rank[second.priority] - rank[first.priority] ||
Date.parse(second.published_at) - Date.parse(first.published_at),
)
}
@@ -151,6 +151,7 @@ export type AppSettings = {
curseforge_source: DownloadSourceMode
bypass_curseforge_download_restrictions: boolean
mojang_auth_source: DownloadSourceMode
ignore_ssl_errors: boolean
theme: ColorTheme
accent_color: AccentColorSetting
@@ -173,6 +174,8 @@ export type AppSettings = {
minimal_home_instance_id: string | null
close_behavior: 'ask' | 'close' | 'lightweight'
ui_scale: number
ui_font: string | null
mono_font: string | null
home_widgets: HomeDashboardConfig | null
terracotta_public_nodes: string[]
@@ -215,6 +218,8 @@ type LegacyMirrorSettings = {
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
settings.close_behavior ??= 'ask'
settings.ui_scale ??= 100
settings.ui_font ??= null
settings.mono_font ??= null
const hasLegacySettings =
typeof settings.use_minecraft_mirror === 'boolean' &&
typeof settings.use_modrinth_mirror === 'boolean' &&
@@ -241,6 +246,7 @@ function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings)
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_curseforge_mirror)
settings.bypass_curseforge_download_restrictions ??= true
settings.mojang_auth_source ??= 'auto'
settings.ignore_ssl_errors ??= false
settings.terracotta_public_nodes ??= ['wss://center.node.1tmc.top']
settings.feature_flags ??= { ...DEFAULT_FEATURE_FLAGS }
for (const [key, value] of Object.entries(DEFAULT_FEATURE_FLAGS)) {
@@ -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 })
}
@@ -0,0 +1,19 @@
import { invoke } from '@tauri-apps/api/core'
import type { SystemFontFamily } from './font-family.ts'
let cached: Promise<SystemFontFamily[]> | null = null
/**
* Installed families are enumerated by the host once per session; installing a
* font while the launcher runs shows up after a restart.
*/
export function getSystemFontFamilies(): Promise<SystemFontFamily[]> {
cached ??= invoke<SystemFontFamily[]>('plugin:fonts|fonts_get_system_fonts').catch((error) => {
// A failed scan must not poison the session for a later retry.
cached = null
throw error
})
return cached
}
+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
+680 -1
View File
@@ -640,6 +640,57 @@
"app.appearance-settings.auto-hide-downloads-button.description": {
"message": "没有进行中的下载任务时隐藏侧边栏中的下载按钮,有任务时自动显示。"
},
"app.appearance-settings.fonts.title": {
"message": "字体"
},
"app.appearance-settings.fonts.description": {
"message": "选择启动器界面与日志使用的字体。"
},
"app.appearance-settings.fonts.ui-font": {
"message": "界面字体"
},
"app.appearance-settings.fonts.ui-font-description": {
"message": "应用于启动器界面。仅列出系统已安装的字体。"
},
"app.appearance-settings.fonts.mono-font": {
"message": "等宽字体"
},
"app.appearance-settings.fonts.mono-font-description": {
"message": "用于日志、服务器控制台等等宽内容。"
},
"app.appearance-settings.fonts.default": {
"message": "启动器默认"
},
"app.appearance-settings.fonts.monospace": {
"message": "等宽"
},
"app.appearance-settings.fonts.missing": {
"message": "未安装"
},
"app.appearance-settings.fonts.search-placeholder": {
"message": "搜索已安装的字体"
},
"app.appearance-settings.fonts.no-results": {
"message": "没有匹配的字体"
},
"app.appearance-settings.fonts.load-failed": {
"message": "无法读取已安装的字体列表。"
},
"app.appearance-settings.fonts.retry": {
"message": "重试"
},
"app.appearance-settings.fonts.reset": {
"message": "重置"
},
"app.appearance-settings.fonts.preview": {
"message": "预览"
},
"app.appearance-settings.fonts.ui-preview-sample": {
"message": "永和九年,岁在癸丑,暮春之初。"
},
"app.appearance-settings.fonts.mono-preview-sample": {
"message": "const x = () => 0; // 等宽"
},
"app.appearance-settings.auto-hide-downloads-button.title": {
"message": "自动隐藏下载按钮"
},
@@ -5995,6 +6046,30 @@
"app.onboarding.instance-actions.title": {
"message": "主操作台"
},
"app.onboarding.instance-content-tab.title": {
"message": "内容"
},
"app.onboarding.instance-content-tab.description": {
"message": "在这里管理实例的 Mod、资源包与光影。可搜索、启用或禁用条目、检查更新,也能继续浏览安装新内容。"
},
"app.onboarding.instance-files-tab.title": {
"message": "文件"
},
"app.onboarding.instance-files-tab.description": {
"message": "浏览实例目录,用内置编辑器直接打开配置文件或日志,无需离开启动器。"
},
"app.onboarding.instance-worlds-tab.title": {
"message": "世界"
},
"app.onboarding.instance-worlds-tab.description": {
"message": "管理存档世界:备份、重命名、编辑世界设置,或打开世界所在文件夹。"
},
"app.onboarding.instance-logs-tab.title": {
"message": "日志"
},
"app.onboarding.instance-logs-tab.description": {
"message": "查看实例的实时日志,排查崩溃与报错,不必再翻找文件。"
},
"app.onboarding.instance-tabs.description": {
"message": "内容、文件、截图、世界和日志都在这些标签里。乱中有序。"
},
@@ -7195,6 +7270,12 @@
"app.settings.resources.curseforge-restriction-bypass-description": {
"message": "当 CurseForge 不提供下载地址时,根据文件信息推导其 CDN 地址并尝试自动下载。关闭后将使用手动下载流程。"
},
"app.settings.resources.ignore-ssl-errors": {
"message": "忽略 SSL 证书错误"
},
"app.settings.resources.ignore-ssl-errors-description": {
"message": "跳过启动器网络请求的 TLS 证书校验。仅当你信任当前网络并了解其安全风险时才启用。"
},
"app.settings.resources.database-backups": {
"message": "应用数据库备份"
},
@@ -10683,5 +10764,603 @@
},
"app.navigation.screenshots": {
"message": "截图"
}
},
"instance.backups.added-size": {
"message": "新增存储 {size}"
},
"instance.backups.all-included": {
"message": "实例中的所有文件和文件夹都会纳入备份。"
},
"instance.backups.backup-now": {
"message": "立即备份"
},
"instance.backups.cancel": {
"message": "取消备份"
},
"instance.backups.choose": {
"message": "默认备份实例中的全部内容,请添加不需要备份的文件或文件夹。"
},
"instance.backups.delete": {
"message": "删除备份"
},
"instance.backups.delete-body": {
"message": "此快照将被永久删除,不再使用的仓库文件也会一并清理。"
},
"instance.backups.delete-title": {
"message": "删除此备份?"
},
"instance.backups.description": {
"message": "在实例关闭时,为整个实例创建去重快照。"
},
"instance.backups.disable": {
"message": "关闭备份"
},
"instance.backups.disable-blocked": {
"message": "必须先删除全部快照才能关闭备份。"
},
"instance.backups.edit-exclusions": {
"message": "编辑排除项"
},
"instance.backups.empty": {
"message": "尚未创建任何备份。"
},
"instance.backups.enable": {
"message": "启用备份"
},
"instance.backups.excluded-paths": {
"message": "不备份:{paths}"
},
"instance.backups.exclusions.add-file": {
"message": "添加忽略文件"
},
"instance.backups.exclusions.add-folder": {
"message": "添加忽略文件夹"
},
"instance.backups.exclusions.empty": {
"message": "没有忽略任何文件或文件夹。"
},
"instance.backups.exclusions.remove": {
"message": "从排除项中移除 {path}"
},
"instance.backups.loading": {
"message": "正在加载备份设置……"
},
"instance.backups.progress-bytes": {
"message": "已处理 {processed},共 {total}"
},
"instance.backups.restore": {
"message": "恢复备份"
},
"instance.backups.restore-body": {
"message": "即将进行的操作:新增 {added} 个文件,修改 {modified} 个文件,删除 {deleted} 个文件。是否继续?"
},
"instance.backups.restore-title": {
"message": "恢复此备份?"
},
"instance.backups.save-exclusions": {
"message": "保存排除项"
},
"instance.backups.snapshot-meta": {
"message": "{files, plural, other {# 个文件}} · {size}"
},
"instance.backups.snapshots": {
"message": "快照"
},
"instance.backups.stage.applying": {
"message": "正在应用变更文件"
},
"instance.backups.stage.failed": {
"message": "备份失败"
},
"instance.backups.stage.hashing": {
"message": "正在计算文件哈希"
},
"instance.backups.stage.interrupted": {
"message": "备份任务已中断"
},
"instance.backups.stage.materializing": {
"message": "正在准备变更文件"
},
"instance.backups.stage.preview": {
"message": "正在分析恢复变更"
},
"instance.backups.stage.queued": {
"message": "等待开始"
},
"instance.backups.stage.saving": {
"message": "正在保存快照索引"
},
"instance.backups.stage.scanning": {
"message": "正在扫描文件"
},
"instance.backups.stage.validating": {
"message": "正在验证恢复任务"
},
"instance.backups.title": {
"message": "实例备份"
},
"instance.backups.unavailable": {
"message": "备份不可用"
},
"instance.backups.unavailable.direct-linked": {
"message": "不支持备份直接关联到其他启动器的实例。"
},
"instance.backups.unavailable.external-directory": {
"message": "不支持备份使用外部游戏目录的实例。"
},
"instance.backups.unavailable.not-installed": {
"message": "请先完成实例安装,再启用备份。"
},
"instance.backups.unavailable.root-link": {
"message": "不支持备份根文件夹为链接的实例。"
},
"instance.backups.unavailable.root-missing": {
"message": "实例文件夹不存在。"
},
"instance.backups.unavailable.symlink": {
"message": "不支持备份以符号链接导入的实例。"
},
"settings.backups.repository.change": {
"message": "更改文件夹"
},
"settings.backups.repository.description": {
"message": "存放去重后的实例备份数据及其独立索引数据库。"
},
"settings.backups.repository.location": {
"message": "位置"
},
"settings.backups.repository.location-description": {
"message": "更改位置会移动完整仓库。请选择新的或空的文件夹。"
},
"settings.backups.repository.moving": {
"message": "正在移动仓库……"
},
"settings.backups.repository.moving-progress": {
"message": "正在移动备份仓库:已处理 {processed},共 {total}"
},
"settings.backups.repository.title": {
"message": "备份文件仓库"
},
"settings.backups.repository.unavailable": {
"message": "仓库不可用"
},
"settings.backups.repository.usage": {
"message": "存储占用"
},
"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": "暂无公告" }
}
@@ -572,6 +572,57 @@
"app.appearance-settings.auto-hide-downloads-button.description": {
"message": "沒有進行中的下載任務時隱藏側邊欄中的下載按鈕,有任務時自動顯示。"
},
"app.appearance-settings.fonts.title": {
"message": "字型"
},
"app.appearance-settings.fonts.description": {
"message": "選擇啟動器介面與日誌使用的字型。"
},
"app.appearance-settings.fonts.ui-font": {
"message": "介面字型"
},
"app.appearance-settings.fonts.ui-font-description": {
"message": "套用於啟動器介面。僅列出系統已安裝的字型。"
},
"app.appearance-settings.fonts.mono-font": {
"message": "等寬字型"
},
"app.appearance-settings.fonts.mono-font-description": {
"message": "用於日誌、伺服器主控台等等寬內容。"
},
"app.appearance-settings.fonts.default": {
"message": "啟動器預設"
},
"app.appearance-settings.fonts.monospace": {
"message": "等寬"
},
"app.appearance-settings.fonts.missing": {
"message": "未安裝"
},
"app.appearance-settings.fonts.search-placeholder": {
"message": "搜尋已安裝的字型"
},
"app.appearance-settings.fonts.no-results": {
"message": "沒有符合的字型"
},
"app.appearance-settings.fonts.load-failed": {
"message": "無法讀取已安裝的字型清單。"
},
"app.appearance-settings.fonts.retry": {
"message": "重試"
},
"app.appearance-settings.fonts.reset": {
"message": "重設"
},
"app.appearance-settings.fonts.preview": {
"message": "預覽"
},
"app.appearance-settings.fonts.ui-preview-sample": {
"message": "永和九年,歲在癸丑,暮春之初。"
},
"app.appearance-settings.fonts.mono-preview-sample": {
"message": "const x = () => 0; // 等寬"
},
"app.appearance-settings.auto-hide-downloads-button.title": {
"message": "自動隱藏下載按鈕"
},
@@ -6827,6 +6878,12 @@
"app.settings.resources.curseforge-restriction-bypass-description": {
"message": "當 CurseForge 不提供下載地址時,根據檔案資訊推導其 CDN 地址並嘗試自動下載。關閉後將使用手動下載流程。"
},
"app.settings.resources.ignore-ssl-errors": {
"message": "忽略 SSL 憑證錯誤"
},
"app.settings.resources.ignore-ssl-errors-description": {
"message": "跳過啟動器網路請求的 TLS 憑證檢查。僅當你信任目前網路並了解其安全風險時才啟用。"
},
"app.settings.resources.database-backups": {
"message": "應用程式資料庫備份"
},
@@ -10138,5 +10195,170 @@
},
"app.instance.dependencies.zoom-out": {
"message": "縮小"
},
"instance.backups.added-size": {
"message": "新增存储 {size}"
},
"instance.backups.all-included": {
"message": "实例中的所有文件和文件夹都会纳入备份。"
},
"instance.backups.backup-now": {
"message": "立即备份"
},
"instance.backups.cancel": {
"message": "取消备份"
},
"instance.backups.choose": {
"message": "默认备份实例中的全部内容,请添加不需要备份的文件或文件夹。"
},
"instance.backups.delete": {
"message": "删除备份"
},
"instance.backups.delete-body": {
"message": "此快照将被永久删除,不再使用的仓库文件也会一并清理。"
},
"instance.backups.delete-title": {
"message": "删除此备份?"
},
"instance.backups.description": {
"message": "在实例关闭时,为整个实例创建去重快照。"
},
"instance.backups.disable": {
"message": "关闭备份"
},
"instance.backups.disable-blocked": {
"message": "必须先删除全部快照才能关闭备份。"
},
"instance.backups.edit-exclusions": {
"message": "编辑排除项"
},
"instance.backups.empty": {
"message": "尚未创建任何备份。"
},
"instance.backups.enable": {
"message": "启用备份"
},
"instance.backups.excluded-paths": {
"message": "不备份:{paths}"
},
"instance.backups.exclusions.add-file": {
"message": "添加忽略文件"
},
"instance.backups.exclusions.add-folder": {
"message": "添加忽略文件夹"
},
"instance.backups.exclusions.empty": {
"message": "没有忽略任何文件或文件夹。"
},
"instance.backups.exclusions.remove": {
"message": "从排除项中移除 {path}"
},
"instance.backups.loading": {
"message": "正在加载备份设置……"
},
"instance.backups.progress-bytes": {
"message": "已处理 {processed},共 {total}"
},
"instance.backups.restore": {
"message": "恢复备份"
},
"instance.backups.restore-body": {
"message": "即将进行的操作:新增 {added} 个文件,修改 {modified} 个文件,删除 {deleted} 个文件。是否继续?"
},
"instance.backups.restore-title": {
"message": "恢复此备份?"
},
"instance.backups.save-exclusions": {
"message": "保存排除项"
},
"instance.backups.snapshot-meta": {
"message": "{files, plural, other {# 个文件}} · {size}"
},
"instance.backups.snapshots": {
"message": "快照"
},
"instance.backups.stage.applying": {
"message": "正在应用变更文件"
},
"instance.backups.stage.failed": {
"message": "备份失败"
},
"instance.backups.stage.hashing": {
"message": "正在计算文件哈希"
},
"instance.backups.stage.interrupted": {
"message": "备份任务已中断"
},
"instance.backups.stage.materializing": {
"message": "正在准备变更文件"
},
"instance.backups.stage.preview": {
"message": "正在分析恢复变更"
},
"instance.backups.stage.queued": {
"message": "等待开始"
},
"instance.backups.stage.saving": {
"message": "正在保存快照索引"
},
"instance.backups.stage.scanning": {
"message": "正在扫描文件"
},
"instance.backups.stage.validating": {
"message": "正在验证恢复任务"
},
"instance.backups.title": {
"message": "实例备份"
},
"instance.backups.unavailable": {
"message": "备份不可用"
},
"instance.backups.unavailable.direct-linked": {
"message": "不支持备份直接关联到其他启动器的实例。"
},
"instance.backups.unavailable.external-directory": {
"message": "不支持备份使用外部游戏目录的实例。"
},
"instance.backups.unavailable.not-installed": {
"message": "请先完成实例安装,再启用备份。"
},
"instance.backups.unavailable.root-link": {
"message": "不支持备份根文件夹为链接的实例。"
},
"instance.backups.unavailable.root-missing": {
"message": "实例文件夹不存在。"
},
"instance.backups.unavailable.symlink": {
"message": "不支持备份以符号链接导入的实例。"
},
"settings.backups.repository.change": {
"message": "更改文件夹"
},
"settings.backups.repository.description": {
"message": "存放去重后的实例备份数据及其独立索引数据库。"
},
"settings.backups.repository.location": {
"message": "位置"
},
"settings.backups.repository.location-description": {
"message": "更改位置会移动完整仓库。请选择新的或空的文件夹。"
},
"settings.backups.repository.moving": {
"message": "正在移动仓库……"
},
"settings.backups.repository.moving-progress": {
"message": "正在移动备份仓库:已处理 {processed},共 {total}"
},
"settings.backups.repository.title": {
"message": "备份文件仓库"
},
"settings.backups.repository.unavailable": {
"message": "仓库不可用"
},
"settings.backups.repository.usage": {
"message": "存储占用"
},
"settings.backups.repository.usage-description": {
"message": "实际存储 {stored},对应 {logical} 的备份数据,共 {count, plural, other {# 个快照}}。"
}
}
+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>
@@ -688,11 +688,13 @@ const tabs = computed(() => [
label: formatMessage(messages.contentTab),
href: `${basePath.value}`,
icon: BoxesIcon,
onboardingId: 'instance-tab-content',
},
{
label: formatMessage(messages.filesTab),
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
onboardingId: 'instance-tab-files',
},
{
label: formatMessage(messages.screenshotsTab),
@@ -703,11 +705,13 @@ const tabs = computed(() => [
label: formatMessage(messages.worldsTab),
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
onboardingId: 'instance-tab-worlds',
},
{
label: formatMessage(messages.logsTab),
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
onboardingId: 'instance-tab-logs',
},
])
@@ -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] =
+39
View File
@@ -1,5 +1,11 @@
import { defineStore } from 'pinia'
import {
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
resolveFontFamily,
} from '@/helpers/font-family.ts'
let systemThemeMq: MediaQueryList | null = null
export const DEFAULT_FEATURE_FLAGS = {
@@ -19,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
@@ -130,6 +137,9 @@ export type ThemeStore = {
devMode: boolean
featureFlags: FeatureFlags
uiFont: string | null
monoFont: string | null
}
export const DEFAULT_THEME_STORE: ThemeStore = {
@@ -154,6 +164,9 @@ export const DEFAULT_THEME_STORE: ThemeStore = {
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
uiFont: null,
monoFont: null,
}
export const useTheming = defineStore('themeStore', {
@@ -249,6 +262,32 @@ export const useTheming = defineStore('themeStore', {
`${Math.min(Math.max(this.transparentBackgroundOpacity, 0), 100)}%`,
)
},
/**
* The font tokens are declared on `body` in the shared defaults, so the
* launcher picks up the chosen family by overriding the CSS variable there.
*/
setUiFont() {
const body = document.body
if (this.uiFont) {
body.style.setProperty(
'--font-standard',
resolveFontFamily(this.uiFont, DEFAULT_UI_FONT_STACK),
)
} else {
body.style.removeProperty('--font-standard')
}
},
setMonoFont() {
const body = document.body
if (this.monoFont) {
body.style.setProperty(
'--mono-font',
resolveFontFamily(this.monoFont, DEFAULT_MONO_FONT_STACK),
)
} else {
body.style.removeProperty('--mono-font')
}
},
getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
},
+3 -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"
@@ -16,6 +16,7 @@ dashmap = { workspace = true }
either = { workspace = true }
enumset = { workspace = true, features = ["serde"] }
flate2 = { workspace = true }
fontdb = { workspace = true }
futures = { workspace = true }
hyper = { workspace = true, features = ["server"] }
hyper-util = { workspace = true }
@@ -28,6 +29,7 @@ paste = { workspace = true }
path-util = { workspace = true }
quartz_nbt = { workspace = true, features = ["serde"] }
regex = { workspace = true }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_with = { workspace = true }
+50
View File
@@ -428,6 +428,21 @@ fn main() {
InlinedPlugin::new()
.commands(&[
"instance_remove",
"instance_get_backup_repository_status",
"instance_move_backup_repository",
"instance_get_backup_config",
"instance_normalize_backup_exclusion",
"instance_enable_backups",
"instance_update_backup_exclusions",
"instance_disable_backups",
"instance_start_backup",
"instance_cancel_backup",
"instance_list_backup_operations",
"instance_list_backups",
"instance_delete_backup",
"instance_get_backup_restore_preview",
"instance_restore_backup",
"instance_get_backup_delete_summary",
"instance_create_direct_link",
"instance_sync_direct_links",
"instance_get",
@@ -523,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,
@@ -697,6 +739,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"fonts",
InlinedPlugin::new()
.commands(&["fonts_get_system_fonts"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"drop",
InlinedPlugin::new()
+9
View File
@@ -650,6 +650,15 @@ Function PageLeaveOptions
Abort
${EndIf}
; 若安装位置被选在盘符根目录(如 E:\),自动在其下创建 Starlight 子目录,
; 避免启动器文件直接散落在根目录。用户选的具体子目录不受影响。
${GetRoot} "$INSTDIR" $0
StrCpy $1 "$0" -1
${If} "$INSTDIR" == "$1"
StrCpy $INSTDIR "$INSTDIR\Starlight"
${NSD_SetText} $InstallDirInput $INSTDIR
${EndIf}
${NSD_GetState} $DesktopShortcutCheckbox $DesktopShortcutState
${If} $FreshInstall <> 1
+1 -1
View File
@@ -9,7 +9,7 @@ use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{Emitter, Runtime};
use tauri_plugin_dialog::DialogExt;
use theseus::instance::{get_full_path, list};
use theseus::instance::get_full_path;
const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed";
+91
View File
@@ -0,0 +1,91 @@
use serde::Serialize;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::api::Result;
#[derive(Debug, Clone, Serialize)]
pub struct SystemFontFamily {
pub family: String,
pub monospaced: bool,
}
/// Scanning every installed face costs a few hundred milliseconds, and the
/// collection only changes between sessions, so it is read once per process.
static SYSTEM_FONTS: OnceLock<Vec<SystemFontFamily>> = OnceLock::new();
type FontFamilies = [(String, fontdb::Language)];
/// fontdb lists the English US family first when the font has one, but the
/// lookup is explicit so a face with an unusual name table still resolves to
/// the name the webview matches against.
fn english_family_name(families: &FontFamilies) -> Option<&str> {
families
.iter()
.find(|(_, language)| {
*language == fontdb::Language::English_UnitedStates
})
.or_else(|| families.first())
.map(|(name, _)| name.trim())
.filter(|name| !name.is_empty())
}
#[derive(Default)]
struct FamilyAccumulator {
families: HashMap<String, bool>,
}
impl FamilyAccumulator {
fn add(&mut self, families: &FontFamilies, monospaced: bool) {
let Some(name) = english_family_name(families) else {
return;
};
self.families
.entry(name.to_owned())
.and_modify(|flag| *flag |= monospaced)
.or_insert(monospaced);
}
fn into_sorted_families(self) -> Vec<SystemFontFamily> {
let mut families: Vec<SystemFontFamily> = self
.families
.into_iter()
.map(|(family, monospaced)| SystemFontFamily { family, monospaced })
.collect();
families.sort_by_cached_key(|entry| entry.family.to_lowercase());
families
}
}
fn collect_system_fonts() -> Vec<SystemFontFamily> {
let mut database = fontdb::Database::new();
database.load_system_fonts();
let mut accumulator = FamilyAccumulator::default();
for face in database.faces() {
accumulator.add(&face.families, face.monospaced);
}
accumulator.into_sorted_families()
}
#[tauri::command]
pub async fn fonts_get_system_fonts() -> Result<Vec<SystemFontFamily>> {
if let Some(fonts) = SYSTEM_FONTS.get() {
return Ok(fonts.clone());
}
let fonts = tauri::async_runtime::spawn_blocking(collect_system_fonts)
.await
.map_err(std::io::Error::other)?;
// A concurrent caller may have won the race; the scan is deterministic.
Ok(SYSTEM_FONTS.get_or_init(|| fonts).clone())
}
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("fonts")
.invoke_handler(tauri::generate_handler![fonts_get_system_fonts])
.build()
}
+455 -21
View File
@@ -25,6 +25,21 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("instance")
.invoke_handler(tauri::generate_handler![
instance_remove,
instance_get_backup_repository_status,
instance_move_backup_repository,
instance_get_backup_config,
instance_normalize_backup_exclusion,
instance_enable_backups,
instance_update_backup_exclusions,
instance_disable_backups,
instance_start_backup,
instance_cancel_backup,
instance_list_backup_operations,
instance_list_backups,
instance_delete_backup,
instance_get_backup_restore_preview,
instance_restore_backup,
instance_get_backup_delete_summary,
instance_create_direct_link,
instance_sync_direct_links,
instance_get,
@@ -120,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()
}
@@ -163,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)]
@@ -290,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,
@@ -347,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,
}
}
}
@@ -478,27 +528,53 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
None => Ok(CoreInstanceLink::Unmanaged),
})
.transpose()?,
launch_overrides: Some(InstanceLaunchOverridesPatch {
player: None,
instance_mode: None,
java_path: edit_instance.java_path,
extra_launch_args: edit_instance.extra_launch_args,
custom_env_vars: edit_instance.custom_env_vars,
memory: edit_instance.memory,
force_fullscreen: edit_instance.force_fullscreen,
maximize_window: edit_instance.maximize_window,
game_resolution: edit_instance.game_resolution,
launch_preparation_timeout: edit_instance
.launch_preparation_timeout,
hooks: edit_instance.hooks,
}),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: edit_instance.game_version,
protocol_version: Some(None),
loader: edit_instance.loader,
loader_version: edit_instance.loader_version,
}),
launch_overrides: {
let has_launch_change = edit_instance.java_path.is_some()
|| edit_instance.extra_launch_args.is_some()
|| edit_instance.custom_env_vars.is_some()
|| edit_instance.memory.is_some()
|| edit_instance.force_fullscreen.is_some()
|| 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.window_title.is_some();
if has_launch_change {
Some(InstanceLaunchOverridesPatch {
player: None,
instance_mode: None,
java_path: edit_instance.java_path,
extra_launch_args: edit_instance.extra_launch_args,
custom_env_vars: edit_instance.custom_env_vars,
memory: edit_instance.memory,
force_fullscreen: edit_instance.force_fullscreen,
maximize_window: edit_instance.maximize_window,
game_resolution: edit_instance.game_resolution,
launch_preparation_timeout: edit_instance
.launch_preparation_timeout,
hooks: edit_instance.hooks,
window_title: edit_instance.window_title,
})
} else {
None
}
},
content_set_patch: {
let has_content_change = edit_instance.game_version.is_some()
|| edit_instance.loader.is_some()
|| edit_instance.loader_version.is_some();
if has_content_change {
Some(AppliedContentSetPatch {
source_kind: None,
game_version: edit_instance.game_version,
protocol_version: Some(None),
loader: edit_instance.loader,
loader_version: edit_instance.loader_version,
})
} else {
None
}
},
last_played: None,
submitted_time_played: None,
recent_time_played: None,
@@ -1675,3 +1751,361 @@ pub async fn instance_open_screenshot<R: tauri::Runtime>(
.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(())
}
// ===== 实例备份命令(移植自 Axolotl) =====
#[tauri::command]
pub async fn instance_get_backup_repository_status()
-> Result<theseus::instance::BackupRepositoryStatus> {
Ok(theseus::instance::get_backup_repository_status().await?)
}
#[tauri::command]
pub async fn instance_move_backup_repository(
destination: PathBuf,
) -> Result<uuid::Uuid> {
Ok(theseus::instance::move_backup_repository(destination).await?)
}
#[tauri::command]
pub async fn instance_get_backup_config(
instance_id: &str,
) -> Result<theseus::instance::InstanceBackupConfig> {
Ok(theseus::instance::get_backup_config(instance_id).await?)
}
#[tauri::command]
pub async fn instance_normalize_backup_exclusion(
instance_id: &str,
selected_path: PathBuf,
kind: theseus::instance::BackupExclusionKind,
) -> Result<theseus::instance::BackupExclusion> {
Ok(theseus::instance::get_backup_exclusion_from_path(
instance_id,
selected_path,
kind,
)
.await?)
}
#[tauri::command]
pub async fn instance_enable_backups(
instance_id: &str,
excluded_paths: Vec<theseus::instance::BackupExclusion>,
) -> Result<theseus::instance::InstanceBackupConfig> {
Ok(theseus::instance::enable_backups(instance_id, excluded_paths).await?)
}
#[tauri::command]
pub async fn instance_update_backup_exclusions(
instance_id: &str,
excluded_paths: Vec<theseus::instance::BackupExclusion>,
) -> Result<theseus::instance::InstanceBackupConfig> {
Ok(
theseus::instance::update_backup_exclusions(
instance_id,
excluded_paths,
)
.await?,
)
}
#[tauri::command]
pub async fn instance_disable_backups(instance_id: &str) -> Result<()> {
Ok(theseus::instance::disable_backups(instance_id).await?)
}
#[tauri::command]
pub async fn instance_start_backup(instance_id: String) -> Result<uuid::Uuid> {
Ok(theseus::instance::start_backup_snapshot(instance_id).await?)
}
#[tauri::command]
pub async fn instance_cancel_backup(operation_id: uuid::Uuid) -> Result<bool> {
Ok(theseus::instance::cancel_backup(operation_id).await?)
}
#[tauri::command]
pub async fn instance_list_backup_operations(
instance_id: Option<String>,
active_only: bool,
) -> Result<Vec<theseus::instance::BackupOperation>> {
Ok(theseus::instance::list_backup_operations(
instance_id.as_deref(),
active_only,
)
.await?)
}
#[tauri::command]
pub async fn instance_list_backups(
instance_id: &str,
) -> Result<Vec<theseus::instance::BackupSnapshot>> {
Ok(theseus::instance::list_backup_snapshots(instance_id).await?)
}
#[tauri::command]
pub async fn instance_delete_backup(snapshot_id: &str) -> Result<()> {
Ok(theseus::instance::delete_backup_snapshot(snapshot_id).await?)
}
#[tauri::command]
pub async fn instance_get_backup_restore_preview(
snapshot_id: &str,
) -> Result<uuid::Uuid> {
Ok(theseus::instance::get_backup_restore_preview(snapshot_id).await?)
}
#[tauri::command]
pub async fn instance_restore_backup(
snapshot_id: &str,
plan_token: &str,
) -> Result<uuid::Uuid> {
Ok(theseus::instance::restore_snapshot(snapshot_id, plan_token).await?)
}
#[tauri::command]
pub async fn instance_get_backup_delete_summary(
instance_id: &str,
) -> 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(())
}
+1
View File
@@ -36,6 +36,7 @@ pub mod curseforge;
pub mod datapacks;
pub mod drop;
pub mod files;
pub mod fonts;
pub mod friends;
pub mod worlds;
+3 -1
View File
@@ -33,7 +33,9 @@ pub async fn settings_set(
app: tauri::AppHandle<impl Runtime>,
settings: Settings,
) -> Result<()> {
settings::set(settings).await?;
settings::set(settings.clone()).await?;
let state = State::get().await?;
state.update_http_client_for_settings(&settings).await?;
let _ = app.emit("settings", ());
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(
+1
View File
@@ -947,6 +947,7 @@ fn main() {
.plugin(api::datapacks::init())
.plugin(api::drop::init())
.plugin(api::files::init())
.plugin(api::fonts::init())
.plugin(api::friends::init())
.plugin(api::worlds::init())
.plugin(api::terracotta::init())
+81 -1
View File
@@ -1,5 +1,6 @@
use crate::api::Result;
use futures::StreamExt;
use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
@@ -26,6 +27,82 @@ const STARLIGHT_UPDATE_BASE_URL: &str = "https://skin.starlight.cool/";
const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(15 * 60);
// semver compares prerelease identifiers lexically, so "beta10" < "beta9"
// (because the character '1' sorts before '9'). Our release names use a
// "betaN" scheme where N is a decimal counter, so a strict lexical compare
// would wrongly report an older beta as the latest. Use a comparator that
// treats the trailing digits of a prerelease identifier numerically.
fn is_remote_newer(current: &Version, remote: &Version) -> bool {
use std::cmp::Ordering;
let core = (remote.major, remote.minor, remote.patch).cmp(&(
current.major,
current.minor,
current.patch,
));
if core != Ordering::Equal {
return core == Ordering::Greater;
}
// Same x.y.z. A final release outranks any prerelease of that version.
match (current.pre.is_empty(), remote.pre.is_empty()) {
(true, true) => false,
(true, false) => false,
(false, true) => true,
(false, false) => {
compare_prerelease(remote.pre.as_str(), current.pre.as_str())
== Ordering::Greater
}
}
}
/// Split a prerelease identifier into its non-digit prefix and an optional
/// trailing decimal number (so "beta10" -> ("beta", Some(10))).
fn split_prerelease_identifier(segment: &str) -> (&str, Option<u64>) {
let digit_start = segment
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| i);
match digit_start {
Some(i) => (&segment[..i], segment[i..].parse::<u64>().ok()),
None => (segment, None),
}
}
/// Compare two prerelease strings, treating a trailing run of ASCII digits
/// as a decimal number (so "beta10" > "beta9"). All other segments fall back
/// to a plain lexical comparison.
fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let left_segments: Vec<&str> = left.split('.').collect();
let right_segments: Vec<&str> = right.split('.').collect();
for (l, r) in left_segments.iter().zip(right_segments.iter()) {
let (l_prefix, l_num) = split_prerelease_identifier(l);
let (r_prefix, r_num) = split_prerelease_identifier(r);
match (l_num, r_num) {
(Some(ln), Some(rn)) if l_prefix == r_prefix => match ln.cmp(&rn) {
Ordering::Equal => continue,
other => return other,
},
_ => {
let prefix_cmp = l_prefix.cmp(r_prefix);
if prefix_cmp != Ordering::Equal {
return prefix_cmp;
}
let num_cmp = l_num.cmp(&r_num);
if num_cmp != Ordering::Equal {
return num_cmp;
}
}
}
}
left_segments.len().cmp(&right_segments.len())
}
// ── Shared types ─────────────────────────────────────────────────
#[derive(Serialize)]
@@ -228,7 +305,10 @@ async fn check_with_endpoints<R: Runtime>(
.header("Accept", "application/json")?
.header("X-Axolotl-Channel", channel)?
.header("X-Axolotl-Platform", platform)?
.header("X-Axolotl-Version", current_version)?;
.header("X-Axolotl-Version", current_version)?
.version_comparator(|current, release| {
is_remote_newer(&current, &release.version)
});
#[cfg(target_os = "windows")]
{
+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,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n ui_scale,\n version\n FROM settings\n ",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n ui_scale,\n ui_font,\n backup_repository_path,\n mono_font,\n version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -299,8 +299,23 @@
"type_info": "Float"
},
{
"name": "version",
"name": "ui_font",
"ordinal": 59,
"type_info": "Text"
},
{
"name": "backup_repository_path",
"ordinal": 60,
"type_info": "Text"
},
{
"name": "mono_font",
"ordinal": 61,
"type_info": "Text"
},
{
"name": "version",
"ordinal": 62,
"type_info": "Integer"
}
],
@@ -367,8 +382,11 @@
false,
null,
false,
null,
null,
null,
false
]
},
"hash": "a00177609aec1a056ec47d46d4acd6d1a6244434991d3624b70c7734a2ab85c3"
"hash": "52d2d61ed9239875d6188417f74bbde439c49582e1e81f5acb7b52d769a1bc18"
}
+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,76 @@
CREATE TABLE repository_meta (
id INTEGER PRIMARY KEY CHECK (id = 0),
schema_name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
INSERT INTO repository_meta (id, schema_name, created_at)
VALUES (0, 'axolotl-instance-backups', unixepoch('subsec') * 1000);
CREATE TABLE instance_backup_configs (
instance_id TEXT PRIMARY KEY,
instance_name TEXT NOT NULL,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL
);
CREATE TABLE backup_selections (
instance_id TEXT NOT NULL REFERENCES instance_backup_configs(instance_id) ON DELETE CASCADE,
path TEXT NOT NULL,
PRIMARY KEY (instance_id, path)
);
CREATE TABLE backup_snapshots (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instance_backup_configs(instance_id) ON DELETE CASCADE,
instance_name TEXT NOT NULL,
created_at INTEGER NOT NULL,
file_count INTEGER NOT NULL,
symlink_count INTEGER NOT NULL,
logical_size INTEGER NOT NULL,
added_size INTEGER NOT NULL
);
CREATE INDEX backup_snapshots_instance_created
ON backup_snapshots(instance_id, created_at DESC);
CREATE TABLE snapshot_roots (
snapshot_id TEXT NOT NULL REFERENCES backup_snapshots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
existed INTEGER NOT NULL,
PRIMARY KEY (snapshot_id, path)
);
CREATE TABLE backup_objects (
hash TEXT PRIMARY KEY,
size INTEGER NOT NULL,
ref_count INTEGER NOT NULL CHECK (ref_count >= 0)
);
CREATE TABLE snapshot_entries (
snapshot_id TEXT NOT NULL REFERENCES backup_snapshots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory', 'symlink')),
object_hash TEXT REFERENCES backup_objects(hash),
size INTEGER NOT NULL DEFAULT 0,
modified_at INTEGER,
unix_mode INTEGER,
link_target TEXT,
link_is_directory INTEGER,
PRIMARY KEY (snapshot_id, path),
CHECK ((kind = 'file' AND object_hash IS NOT NULL) OR (kind != 'file' AND object_hash IS NULL)),
CHECK ((kind = 'symlink' AND link_target IS NOT NULL) OR (kind != 'symlink' AND link_target IS NULL))
);
CREATE INDEX snapshot_entries_object_hash
ON snapshot_entries(object_hash) WHERE object_hash IS NOT NULL;
CREATE TABLE object_gc_queue (
hash TEXT PRIMARY KEY,
queued_at INTEGER NOT NULL
);
CREATE TABLE pending_instance_deletions (
instance_id TEXT PRIMARY KEY,
marked_at INTEGER NOT NULL
);
@@ -0,0 +1,19 @@
ALTER TABLE backup_snapshots
ADD COLUMN scope_mode TEXT NOT NULL DEFAULT 'included_roots'
CHECK (scope_mode IN ('included_roots', 'excluded_paths'));
DROP TABLE backup_selections;
CREATE TABLE backup_exclusions (
instance_id TEXT NOT NULL REFERENCES instance_backup_configs(instance_id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory')),
PRIMARY KEY (instance_id, path)
);
CREATE TABLE snapshot_exclusions (
snapshot_id TEXT NOT NULL REFERENCES backup_snapshots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory')),
PRIMARY KEY (snapshot_id, path)
);
@@ -0,0 +1,26 @@
CREATE TABLE backup_operations (
id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL
CHECK (operation_type IN ('create', 'restore')),
instance_id TEXT NOT NULL,
snapshot_id TEXT,
state TEXT NOT NULL
CHECK (state IN (
'queued', 'scanning', 'hashing', 'saving', 'validating',
'materializing', 'applying', 'completed', 'cancelled', 'failed', 'interrupted'
)),
processed_bytes INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
cancellable INTEGER NOT NULL DEFAULT 1,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER
);
CREATE INDEX backup_operations_instance_created
ON backup_operations(instance_id, created_at DESC);
CREATE INDEX backup_operations_active
ON backup_operations(state, updated_at DESC)
WHERE state NOT IN ('completed', 'cancelled', 'failed', 'interrupted');
@@ -0,0 +1,2 @@
ALTER TABLE backup_operations
ADD COLUMN cancel_requested INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,45 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE backup_operations_new (
id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL
CHECK (operation_type IN ('create', 'restore', 'repository_move')),
instance_id TEXT NOT NULL,
snapshot_id TEXT,
state TEXT NOT NULL
CHECK (state IN (
'queued', 'scanning', 'hashing', 'saving', 'validating',
'materializing', 'applying', 'completed', 'cancelled', 'failed', 'interrupted'
)),
processed_bytes INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
cancellable INTEGER NOT NULL DEFAULT 1,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER,
cancel_requested INTEGER NOT NULL DEFAULT 0
);
INSERT INTO backup_operations_new (
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
)
SELECT
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
FROM backup_operations;
DROP TABLE backup_operations;
ALTER TABLE backup_operations_new RENAME TO backup_operations;
CREATE INDEX backup_operations_instance_created
ON backup_operations(instance_id, created_at DESC);
CREATE INDEX backup_operations_active
ON backup_operations(state, updated_at DESC)
WHERE state NOT IN ('completed', 'cancelled', 'failed', 'interrupted');
PRAGMA foreign_keys = ON;
@@ -0,0 +1,49 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE backup_operations_new (
id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL
CHECK (operation_type IN ('create', 'restore', 'repository_move', 'restore_preview')),
instance_id TEXT NOT NULL,
snapshot_id TEXT,
state TEXT NOT NULL
CHECK (state IN (
'queued', 'scanning', 'hashing', 'saving', 'validating',
'materializing', 'applying', 'completed', 'cancelled', 'failed', 'interrupted'
)),
processed_bytes INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
cancellable INTEGER NOT NULL DEFAULT 1,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER,
cancel_requested INTEGER NOT NULL DEFAULT 0,
preview_added_files INTEGER,
preview_modified_files INTEGER,
preview_deleted_files INTEGER,
preview_plan_token TEXT
);
INSERT INTO backup_operations_new (
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
)
SELECT
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
FROM backup_operations;
DROP TABLE backup_operations;
ALTER TABLE backup_operations_new RENAME TO backup_operations;
CREATE INDEX backup_operations_instance_created
ON backup_operations(instance_id, created_at DESC);
CREATE INDEX backup_operations_active
ON backup_operations(state, updated_at DESC)
WHERE state NOT IN ('completed', 'cancelled', 'failed', 'interrupted');
PRAGMA foreign_keys = ON;
@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN ignore_ssl_errors INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
ALTER TABLE settings ADD COLUMN ui_font TEXT;
ALTER TABLE settings ADD COLUMN mono_font TEXT;
@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN backup_repository_path TEXT;
@@ -0,0 +1,4 @@
CREATE TABLE pending_backup_repository_cleanups (
path TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
@@ -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';
File diff suppressed because it is too large. Load diff
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff. Show more