Compare commits

...
Author SHA1 Message Date
AxTps 7f3ab9d79b fix: 补 EditInstance DTO 缺失的 window_title 字段与透传
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:23:49 +08:00
AxTps 037bff2db3 Merge branch 'main' into feat/launch-customization
Sync source to CNB / Sync Git ref (push) Skipped
解决 en-US/zh-CN locale JSON 的末尾追加冲突:窗口标题键与公告中心键均保留。
2026-10-10 22:07:19 +08:00
AxTps 1406f7ded3 fix: 补 ProcessMetadata 测试构造点缺失的 window_title
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:01:42 +08:00
Ap_Tx c54acd2e36 fix: 补齐盔甲预览的中文翻译 (#11)
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (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
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / guardrails (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
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / guardrails (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
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 / 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
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / guardrails (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
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (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
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / guardrails (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
Axolotl desktop CI / desktop (macos-latest) (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 / tombi (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
Repository checks / typos (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
Starlight release / Build (Linux arm64) (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (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
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 / tombi (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
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
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:39:09 +08:00
AxTps 386db4bdb5 fix: 豁免被删除的 000050 迁移文件
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 (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 (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Repository checks / typos (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: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
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 / tombi (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
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
Repository checks / typos (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 (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 / tombi (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (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
Repository checks / typos (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
把两张同步包表的建表语句并入 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
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 / tombi (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
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
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
移植跨实例同步时遗漏了这两张表,导致 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 / 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 (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 (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Repository checks / typos (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: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
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 / tombi (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
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
Repository checks / typos (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
Repository checks / tombi (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
Axolotl desktop CI / website (push) Has been cancelled
10 月 4 日(Starlight 首个发布日 2014-10-04)当天,启动器顶部显示周年横幅并飘落蛋糕/彩带,周年数按年份-2014 计算。新增 helpers/anniversary.ts 与 AnniversaryCelebration.vue;开发者页加入 force_anniversary_celebration 功能开关用于随时预览。
2026-10-04 11:23:27 +08:00
Ap_Tx 8a447f33f6 Merge pull request #7 from ApTx2012/feat/instance-sync
Axolotl desktop CI / 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
Repository checks / tombi (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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (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
Repository checks / tombi (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
Axolotl desktop CI / website (push) Has been cancelled
- requireHostedGameAccount 在无游戏账户但皮肤站已登录时,自动认证第一个非 Mojang 玩家

- 新增测试覆盖该自动认证流程
2026-10-03 19:14:25 +08:00
Axolotl Release Bot 214066ae5f chore(website): update download metadata to v1.0.1 [skip ci] 2026-10-03 07:08:29 +00:00
AxTps 2f34faa50f fix: 1.0.1 更新公告改到正确的 catalog.ts(网站 catalog.json 回滚)
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 (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 (windows-latest) (push) Has been cancelled
Repository checks / tombi (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
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Repository checks / typos (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-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 (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 / tombi (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (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
Repository checks / typos (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-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
Repository checks / tombi (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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / tombi (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
Repository checks / typos (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
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 / tombi (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
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
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 (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 / tombi (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (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
Repository checks / typos (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: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
Repository checks / tombi (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
Axolotl desktop CI / website (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
Repository checks / tombi (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
Axolotl desktop CI / website (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 / 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 (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (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
AxTps ae9a762439 fix: 修复 routes.js 中多人联机子路由缺失对象起始括号导致的语法错误
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 (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 / tombi (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
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
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 15:07:58 +08:00
AxTps 8480927b99 docs: 添加 1.0.1-beta9 更新公告
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 (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 / tombi (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (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
Repository checks / typos (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 14:24:25 +08:00
AxTps 24f5761317 fix: 补齐 FeatureFlag 枚举的 MultiplayerInApp,修复极简主页 settings_set 反序列化报错
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 (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 13:43:34 +08:00
AxTps 3d003f9b31 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
Repository checks / tombi (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
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 13:23:37 +08:00
AxTps 1b487da5c2 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 (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 13:20:04 +08:00
AxTps b310018c38 docs: 添加 1.0.1-beta8 更新公告
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 (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 / tombi (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
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
Sync LobeHub models / sync (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 11:03:59 +08:00
AxTps fbde37f5a0 fix: 补全新增 command 的 Tauri 权限
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / tombi (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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 10:58:11 +08:00
AxTps e6f9987694 fix: 修复侧边栏导航项损坏与 hosted_has_game_account 权限缺失 2026-10-02 10:50:53 +08:00
AxTps 5a08a1ed3a 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
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 / tombi (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
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
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 09:09:53 +08:00
AxTps e51abeb5ad docs: 添加 1.0.1-beta7 更新公告
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / tombi (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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 08:26:24 +08:00
Ap_Tx 4c13f849eb Merge pull request #5 from ApTx2012/feat/screenshot-center
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
Repository checks / tombi (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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
feat: 移植截图中心(浏览/分组/编辑/导出)
2026-10-02 08:21:07 +08:00
AxTps 00bfe2de9f fix: 补上 fs_scope 所需的 FsExt trait 导入
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 06:47:40 +08:00
AxTps 95b9c044eb feat: 补充图像编辑器所需的图标
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 06:33:45 +08:00
AxTps 911a2cb872 feat: 补充图像编辑器所需的图标
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 06:22:03 +08:00
AxTps 0a60695dc9 fix: 补上缺失的 instance-groups helper
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 06:09:37 +08:00
AxTps 643d7276d7 chore: 豁免文件名与占位符类文案的翻译检查
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 23:04:33 +08:00
AxTps e066c6a8d7 i18n: 补全截图中心与图像编辑器的中文翻译
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 23:00:04 +08:00
AxTps b5406c2da0 feat: 移植截图中心(浏览/分组/编辑/导出)
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
2026-10-01 22:45:02 +08:00
AxTps 300df18aca chore: 更新 sqlx 缓存并修复截图模块告警
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 21:40:56 +08:00
AxTps 5efbe048eb chore: cargo fmt
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 21:24:05 +08:00
AxTps ead5d96b78 feat: 移植截图中心后端(索引/分组/编辑保存)
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 21:08:57 +08:00
AxTps e646598078 fix: 导览首步锚点缺失时不再自动跳过
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / tombi (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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-01 18:52:50 +08:00
AxTps f1ff19e4b8 chore: 更新 sqlx 查询缓存(新增 ui_scale 列)
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / tombi (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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-01 17:16:49 +08:00
AxTps 34cf186c33 feat: 界面缩放改用后端持久化 + 原生 webview zoom
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 (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-01 16:21:30 +08:00
AxTps 81b7219085 feat: 恢复多人联机(陶瓦)侧边栏入口,挂于功能开关后
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
Repository checks / tombi (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
Axolotl desktop CI / website (push) Has been cancelled
2026-10-01 15:59:18 +08:00
AxTps 3cdc4951e0 chore: 发布 1.0.1-beta6 公告
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 (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 / tombi (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
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
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-01 13:02:39 +08:00
AxTps f839796585 feat: 功能开关页显示中文名 2026-10-01 12:57:48 +08:00
AxTps e25135934f fix: 导览气泡让开左侧导航栏
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / tombi (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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-01 11:46:59 +08:00
AxTps 02a8004fd5 fix: minimal 主页补充导览锚点
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 (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 11:40:23 +08:00
AxTps 6b1d2e1943 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
Repository checks / tombi (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
Axolotl desktop CI / website (push) Has been cancelled
2026-10-01 11:27:27 +08:00
AxTps 6912e0aeb1 chore: 发布 1.0.1-beta5 公告
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 (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 / tombi (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
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
Sync LobeHub models / sync (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
- catalog.ts 增量新增 1.0.1-beta5 公告(重做导览、功能开关修复、
  编译错误修复);
- 网站 catalog.json 重新生成。
2026-10-01 09:35:05 +08:00
AxTps c7d1e44719 feat: 重做新手导览
按新的顺序重写 main 导览(10 个内容点,导航 + 高亮共 16 步):
主页、登录 StarLight、侧栏公告、创建 StarLight 实例、发现内容、
皮肤选择器、实例库、实验室、下载、设置的更新页。

- onboardingConfig.ts 重写 onboardingTours.main,并新增 home-intro/
  account-login/announcement/create-starlight/settings-updates 文案;
- HomeAnnouncementEntry.vue 增加 data-onboarding-id="home-announcement"
  锚点;
- en-US / zh-CN 补齐上述新增文案。
2026-10-01 09:21:39 +08:00
AxTps 0a6fa0eb35 chore(ci): tombi 改用 npx 安装
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 (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
taiki-e/install-action@v2.81.9 不支持 tombi 1.6.1(报 "version
1.6.1 is not supported")。改用 npx 安装 tombi@1.6.1,避免
install-action 的版本白名单限制,并与本地格式化版本一致。
2026-10-01 08:20:46 +08:00
AxTps 79fdddc075 chore: 修复仓库检查(tombi 格式、typos 拼写)
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
Repository checks / tombi (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
Axolotl desktop CI / website (push) Has been cancelled
- 移除意外提交的 JVM 崩溃回放日志
  packages/app-lib/java/replay_pid*.log,其字节码签名 `IZ` 被
  typos 判为拼写错误(应为 IS);并在 .gitignore 忽略 replay_pid*.log。
- 用 tombi 格式化 .cargo/config.toml、Cargo.toml、
  apps/app/Cargo.toml、packages/app-lib/Cargo.toml(表/键排序、
  多行内联表压为单行)。
- check-generic.yml 的 tombi 由 1.1.2 升到 1.6.1。
2026-10-01 08:03:12 +08:00
AxTps 7616f60eca 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
Repository checks / tombi (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
Axolotl desktop CI / website (push) Has been cancelled
FeatureFlagSettings.vue 使用了 ref 但未从 vue 导入,导致
<script setup> 顶层抛出 ReferenceError,整页渲染失败(功能开关列表
空白)。补上 ref 的导入。
2026-10-01 07:28:25 +08:00
AxTps 6f77f13a04 chore: 发布 1.0.1-beta4 公告
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 (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 / tombi (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
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
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
- catalog.ts 增量新增 1.0.1-beta4 公告(修复 beta3 的构建错误);
- 网站 catalog.json 重新生成。
2026-09-30 23:47:22 +08:00
AxTps b1f4431767 fix: 修复 DashMap 取值编译错误
hosted.rs 的 has_game_account_in 对 DashMap 使用了不存在的
.values(),导致 theseus 编译失败(E0599)。改为 .iter() 并通过
.value() 取账户值。
2026-09-30 23:43:30 +08:00
408 changed files with 47120 additions and 26644 deletions

No files matched your search

+8 -8
View File
@@ -1,10 +1,3 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target."cfg(windows)"]
rustflags = ["-C", "link-args=/STACK:16777220"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
# Reproducible public-service defaults for downstream desktop builds. Private
# Modrinth services remain disabled by Axolotl capabilities at runtime.
[env]
@@ -12,5 +5,12 @@ MODRINTH_API_BASE_URL = "https://api.modrinth.com"
MODRINTH_API_URL = "https://api.modrinth.com/v2/"
MODRINTH_API_URL_V3 = "https://api.modrinth.com/v3/"
MODRINTH_LAUNCHER_META_URL = "https://launcher-meta.modrinth.com/"
MODRINTH_URL = "https://modrinth.com/"
MODRINTH_SOCKET_URL = "wss://disabled.invalid/"
MODRINTH_URL = "https://modrinth.com/"
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target."cfg(windows)"]
rustflags = ["-C", "link-args=/STACK:16777220"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
+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
+2 -5
View File
@@ -22,8 +22,5 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
with:
tool: tombi@1.1.2
- run: tombi lint
- run: tombi fmt --check
- run: npx --yes tombi@1.6.1 lint
- run: npx --yes tombi@1.6.1 fmt --check
+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
+1
View File
@@ -38,6 +38,7 @@ node_modules
npm-debug.log
yarn-error.log
testem.log
replay_pid*.log
/typings
# System Files
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",
+8 -24
View File
@@ -20,9 +20,7 @@ ariadne = { path = "packages/ariadne" }
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
async-recursion = "1.1.1"
async-trait = "0.1.89"
async-tungstenite = { version = "0.31.0", default-features = false, features = [
"futures-03-sink"
] }
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
async-walkdir = "2.1.0"
async_zip = "0.0.18"
base64 = "0.22.1"
@@ -32,9 +30,7 @@ bytes = "1.10.1"
cc = "1.2.56"
chardetng = "0.1.17"
chrono = "0.4.42"
cidre = { version = "0.15.0", default-features = false, features = [
"macos_15_0"
] }
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
crc32fast = "1.5.0"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
@@ -48,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"
@@ -58,11 +55,7 @@ hmac = "0.12.1"
http = "1.3.1"
hyper = "1.7.0"
hyper-util = "0.1.17"
image = { version = "0.25.5", default-features = false, features = [
"jpeg",
"png",
"webp"
] }
image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "webp"] }
indicatif = "0.18.0"
itertools = "0.14.0"
jsonwebtoken = "9.3.1"
@@ -88,13 +81,10 @@ rand = "=0.8.5" # Locked on 0.8 until p256 updates to 0.9
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
rustls = { version = "0.23.42", default-features = false, features = [
"ring",
"std",
"tls12"
] }
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"
@@ -127,10 +117,7 @@ tempfile = "3.23.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.17"
tokio = "1.47.1"
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring",
"tls12"
] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
tokio-util = "0.7.16"
toml = "0.8.19"
tracing = "0.1.41"
@@ -149,10 +136,7 @@ windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
winreg = "0.55.0"
xz2 = { version = "0.1.7", features = ["static"] }
zbus = "5.11.0"
zhconv = { version = "0.4.1", default-features = false, features = [
"compress",
"opencc"
] }
zhconv = { version = "0.4.1", default-features = false, features = ["compress", "opencc"] }
zip = { version = "6.0.0", default-features = false, features = [
"bzip2",
"deflate",
+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]
+4 -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",
@@ -14,6 +14,9 @@
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@dnd-kit/vue": "^0.5.0",
"@lobehub/icons-static-svg": "1.94.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
+51 -3
View File
@@ -8,6 +8,7 @@ import {
FlaskConicalIcon,
FolderOpenIcon,
HomeIcon,
ImagesIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
@@ -19,6 +20,7 @@ import {
SettingsIcon,
SpinnerIcon,
UserIcon,
UsersIcon,
WorldIcon,
} from '@modrinth/assets'
import {
@@ -70,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'
@@ -92,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'
@@ -141,7 +145,7 @@ import {
isNetworkMetered,
setRestartAfterPendingUpdate,
} from '@/helpers/utils.js'
import { installUiScale } from '@/helpers/ui-scale'
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n, { resolveInitialLocale } from '@/i18n.config'
import {
@@ -313,6 +317,22 @@ const showOnboarding = ref(false)
const onboardingMode = ref('main')
const onboardingSettings = ref(null)
const onboardingReplay = ref(false)
// Expand the sidebar while the onboarding tour is visible so steps that point
// at sidebar entries (account card, announcement, create instance) can find
// their targets. The previous state is restored when the tour ends.
let sidebarStateBeforeOnboarding: boolean | undefined
watch(showOnboarding, (visible) => {
if (visible) {
sidebarStateBeforeOnboarding = sidebarToggled.value
sidebarToggled.value = true
setSidebarExpanded(true)
} else if (sidebarStateBeforeOnboarding !== undefined) {
sidebarToggled.value = sidebarStateBeforeOnboarding
setSidebarExpanded(sidebarStateBeforeOnboarding)
sidebarStateBeforeOnboarding = undefined
}
})
const nativeDecorations = ref(false)
const os = ref('')
@@ -749,6 +769,10 @@ const messages = defineMessages({
id: 'app.navigation.multiplayer',
defaultMessage: 'Multiplayer',
},
screenshots: {
id: 'app.navigation.screenshots',
defaultMessage: 'Screenshots',
},
downloads: {
id: 'app.navigation.downloads',
defaultMessage: 'Downloads',
@@ -1074,6 +1098,7 @@ async function setupApp() {
close_behavior,
developer_mode,
feature_flags,
ui_scale,
} = initialSettings
// Initialize locale from saved settings
@@ -1104,6 +1129,7 @@ async function setupApp() {
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
setUiScale(ui_scale)
themeStore.setThemeState(theme)
await initializeSystemAccentColor()
themeStore.setAccentColor(accent_color)
@@ -1236,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
@@ -2197,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"
@@ -2296,6 +2324,23 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<LibraryIcon />
</NavButton>
<NavButton
v-if="themeStore.featureFlags.multiplayer_in_app"
v-tooltip.right="formatMessage(messages.multiplayer)"
data-onboarding-id="nav-multiplayer"
to="/multiplayer"
:is-primary="(r) => r.path.startsWith('/multiplayer')"
>
<UsersIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.screenshots)"
data-onboarding-id="nav-screenshots"
to="/screenshots"
:is-primary="(r) => r.path.startsWith('/screenshots')"
>
<ImagesIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.lab)"
data-onboarding-id="nav-lab"
@@ -2509,7 +2554,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<div
data-onboarding-id="sidebar-accounts"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.userInformation) }}
</h3>
@@ -32,6 +32,349 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.2-beta4',
version: '1.0.2-beta4',
publishedAt: '2026-10-08',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta4',
'zh-CN': 'Starlight Launcher 1.0.2-beta4',
},
changes: {
added: [
{
'en-US': 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
'zh-CN': '皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
},
{
'en-US': 'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
},
],
changed: [
{
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
'zh-CN': '优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
},
],
fixed: [
{
'en-US': 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
'zh-CN': '修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
},
{
'en-US': 'Fixed instance group changes not saving for some instances.',
'zh-CN': '修复部分实例的分组修改无法保存的问题。',
},
],
},
},
{
id: 'launcher-1.0.2-beta3',
version: '1.0.2-beta3',
publishedAt: '2026-10-04',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta3',
'zh-CN': 'Starlight Launcher 1.0.2-beta3',
},
changes: {
fixed: [
{
'en-US': 'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
'zh-CN': '修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
},
],
},
},
{
id: 'launcher-1.0.2-beta2',
version: '1.0.2-beta2',
publishedAt: '2026-10-04',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta2',
'zh-CN': 'Starlight Launcher 1.0.2-beta2',
},
changes: {
fixed: [
{
'en-US': 'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
'zh-CN': '修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
},
],
},
},
{
id: 'launcher-1.0.2-beta1',
version: '1.0.2-beta1',
publishedAt: '2026-10-04',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta1',
'zh-CN': 'Starlight Launcher 1.0.2-beta1',
},
changes: {
added: [
{
'en-US': 'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
'zh-CN': '跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
},
{
'en-US': 'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
'zh-CN': 'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
},
{
'en-US': 'Added a developer feature flag to preview the anniversary celebration at any time.',
'zh-CN': '新增开发者功能开关,可随时预览周年庆效果。',
},
],
},
},
{
id: 'launcher-1.0.1',
version: '1.0.1',
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',
publishedAt: '2026-10-01',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta6',
'zh-CN': 'Starlight Launcher 1.0.1-beta6',
},
changes: {
fixed: [
{
'en-US':
'Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.',
'zh-CN':
'修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
},
{
'en-US':
'Fixed the onboarding tour target on the minimal home layout.',
'zh-CN': '修复精简主页下导览目标找不到的问题。',
},
],
added: [
{
'en-US': 'Feature flag names in developer settings are now shown in Chinese.',
'zh-CN': '开发者设置中的功能开关名现在显示中文。',
},
],
},
},
{
id: 'launcher-1.0.1-beta5',
version: '1.0.1-beta5',
publishedAt: '2026-10-01',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta5',
'zh-CN': 'Starlight Launcher 1.0.1-beta5',
},
changes: {
added: [
{
'en-US':
'Reworked the onboarding tour, covering the home page, StarLight login, announcements, instance creation, discovery, skins, library, lab, downloads, and update settings in order.',
'zh-CN':
'重做了新手导览,依次介绍主页、StarLight 登录、公告、创建实例、发现内容、皮肤、实例库、实验室、下载与更新设置。',
},
],
fixed: [
{
'en-US':
'Fixed the developer feature flags page rendering blank.',
'zh-CN': '修复开发者“功能开关”页面空白的问题。',
},
{
'en-US':
'Fixed a build error that prevented the launcher from compiling.',
'zh-CN': '修复导致启动器无法编译的构建错误。',
},
],
},
},
{
id: 'launcher-1.0.1-beta4',
version: '1.0.1-beta4',
publishedAt: '2026-09-30',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta4',
'zh-CN': 'Starlight Launcher 1.0.1-beta4',
},
changes: {
fixed: [
{
'en-US':
'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
'zh-CN': '修复 1.0.1-beta3 中导致启动器无法编译的构建错误。',
},
],
},
},
{
id: 'launcher-1.0.1-beta3',
version: '1.0.1-beta3',
@@ -11,6 +11,7 @@ function open() {
<template>
<section
data-onboarding-id="home-announcement"
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
>
<button
@@ -201,7 +201,7 @@ onUnmounted(() => {
data-onboarding-id="home-instances"
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8"
>
<div class="flex w-full max-w-3xl flex-col items-center text-center">
<div data-onboarding-id="home-widget-grid" class="flex w-full max-w-3xl flex-col items-center text-center">
<HomeGreeting :player-name="playerName" variant="minimal" />
<template v-if="selectedInstance">
@@ -0,0 +1,246 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { anniversaryYear, ordinal, shouldCelebrate } from '@/helpers/anniversary'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const messages = defineMessages({
banner: {
id: 'app.anniversary.banner',
defaultMessage: 'Happy {ordinal} Anniversary, Starlight!',
},
})
/** Emoji that drift down from the top of the window. */
const CONFETTI_EMOJI = ['🎂', '🎉', '🎊', '🎈', '✨', '🧁', '🍰']
type Confetti = {
id: number
emoji: string
left: number
delay: number
duration: number
size: number
drift: number
}
const visible = ref(false)
const years = ref(0)
const confetti = ref<Confetti[]>([])
let spawnTimer: number | undefined
let nextId = 0
function spawnConfetti(): void {
const emoji = CONFETTI_EMOJI[Math.floor(Math.random() * CONFETTI_EMOJI.length)]
confetti.value.push({
id: nextId++,
emoji,
left: Math.random() * 100,
delay: 0,
duration: 6 + Math.random() * 5,
size: 1 + Math.random() * 1.4,
drift: (Math.random() - 0.5) * 160,
})
// Keep the DOM small; drop the oldest once we exceed a cap.
if (confetti.value.length > 60) confetti.value.splice(0, 10)
}
const forced = computed(() => themeStore.getFeatureFlag('force_anniversary_celebration'))
function startCelebration(): void {
if (spawnTimer !== undefined) return
years.value = anniversaryYear()
visible.value = true
// Seed a few so the screen is not empty on the first frame.
for (let i = 0; i < 12; i++) spawnConfetti()
spawnTimer = window.setInterval(spawnConfetti, 700)
}
function stopCelebration(): void {
if (spawnTimer !== undefined) {
window.clearInterval(spawnTimer)
spawnTimer = undefined
}
confetti.value = []
visible.value = false
}
watch(
() => shouldCelebrate(forced.value),
(active) => (active ? startCelebration() : stopCelebration()),
{ immediate: true },
)
onUnmounted(stopCelebration)
const bannerText = computed(() =>
formatMessage(messages.banner, { ordinal: ordinal(years.value) }),
)
</script>
<template>
<Transition name="anniversary-fade">
<div v-if="visible" class="anniversary-root" aria-live="polite">
<!-- Falling confetti layer (click-through). -->
<div class="anniversary-confetti" aria-hidden="true">
<span
v-for="piece in confetti"
:key="piece.id"
class="anniversary-piece"
:style="{
left: piece.left + '%',
fontSize: piece.size + 'rem',
animationDelay: piece.delay + 's',
animationDuration: piece.duration + 's',
'--drift': piece.drift + 'px',
}"
>
{{ piece.emoji }}
</span>
</div>
<!-- Top banner. -->
<div class="anniversary-banner">
<span class="anniversary-banner-emoji">🎂</span>
<span class="anniversary-banner-text">{{ bannerText }}</span>
<span class="anniversary-banner-emoji">🎉</span>
</div>
</div>
</Transition>
</template>
<style scoped>
.anniversary-root {
position: fixed;
inset: 0;
/* Above content, below modals/drag overlays (which use z-[200]). */
z-index: 150;
pointer-events: none;
}
.anniversary-confetti {
position: absolute;
inset: 0;
overflow: hidden;
}
.anniversary-piece {
position: absolute;
top: -3rem;
will-change: transform;
animation-name: anniversary-fall;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
@keyframes anniversary-fall {
0% {
transform: translate3d(0, 0, 0) rotate(0deg);
opacity: 0;
}
10% {
opacity: 1;
}
100% {
transform: translate3d(var(--drift, 0), 110vh, 0) rotate(360deg);
opacity: 0.85;
}
}
.anniversary-banner {
position: absolute;
/* 避开原生窗口标题栏(Windows/macOS 系统装饰条会盖住 top:0 的内容);
titlebar-area-height 是 Tauri 暴露的标题栏高度变量,不支持时降级为 0 */
top: env(titlebar-area-height, 0px);
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.4rem 1rem;
font-weight: 700;
font-size: 0.95rem;
letter-spacing: 0.02em;
color: #fff;
/* 半透明深色兜底:即使下面的渐变背景失效,白字也不会在浅色页面上隐形 */
background-color: rgba(20, 16, 28, 0.35);
/* 半透明彩色渐变:让窗口下方的内容能透出来 */
background-image: linear-gradient(
90deg,
rgba(244, 114, 182, 0.8),
rgba(250, 204, 21, 0.8),
rgba(74, 222, 128, 0.8),
rgba(56, 189, 248, 0.8),
rgba(167, 139, 250, 0.8),
rgba(244, 114, 182, 0.8)
);
background-size: 300% 100%;
animation: anniversary-gradient 8s linear infinite;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.55),
0 0 4px rgba(0, 0, 0, 0.35);
}
.anniversary-banner-text {
animation: anniversary-pulse 1.8s ease-in-out infinite;
}
.anniversary-banner-emoji {
animation: anniversary-bounce 1.4s ease-in-out infinite;
}
@keyframes anniversary-gradient {
0% {
background-position: 0% 50%;
}
100% {
background-position: 300% 50%;
}
}
@keyframes anniversary-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
@keyframes anniversary-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
.anniversary-fade-enter-active,
.anniversary-fade-leave-active {
transition: opacity 0.6s ease;
}
.anniversary-fade-enter-from,
.anniversary-fade-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.anniversary-piece,
.anniversary-banner,
.anniversary-banner-text,
.anniversary-banner-emoji {
animation: none;
}
}
</style>
@@ -67,6 +67,23 @@
</div>
</template>
</Dropdown>
<ButtonStyled
v-tooltip="formatMessage(messages.announcements)"
type="transparent"
circular
>
<button
:aria-label="formatMessage(messages.announcements)"
class="relative"
@click="openAnnouncementCenter"
>
<NewspaperIcon />
<span
v-if="announcementUnreadCount"
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
/>
</button>
</ButtonStyled>
<ButtonStyled
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
color="brand"
@@ -190,6 +207,7 @@
<script setup lang="ts">
import {
BellIcon,
NewspaperIcon,
DownloadIcon,
DropdownIcon,
OnlineIndicatorIcon,
@@ -211,7 +229,7 @@ import {
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
@@ -222,6 +240,10 @@ import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
import {
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
} from '@/helpers/remote-announcements'
import type { GameInstance } from '@/helpers/types'
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
@@ -231,6 +253,25 @@ const popupNotificationManager = injectPopupNotificationManager()
const downloadManager = injectDownloadManager()
const { formatMessage } = useVIntl()
const announcementUnreadCount = ref(0)
function openAnnouncementCenter() {
window.dispatchEvent(new CustomEvent(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT))
}
function updateAnnouncementCount(event: Event) {
const detail = (event as CustomEvent<{ unreadKeys?: string[] }>).detail
announcementUnreadCount.value = detail?.unreadKeys?.length ?? 0
}
onMounted(() => {
window.addEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
})
onBeforeUnmount(() => {
window.removeEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
})
type NotificationHistoryItem = {
key: string
createdAt?: number
@@ -370,6 +411,10 @@ const messages = defineMessages({
id: 'app.action-bar.notifications',
defaultMessage: 'Notifications',
},
announcements: {
id: 'app.action-bar.announcements',
defaultMessage: 'Announcements',
},
clearNotifications: {
id: 'app.action-bar.notifications.clear',
defaultMessage: 'Clear all',
@@ -0,0 +1,388 @@
<script setup lang="ts">
import {
Button,
commonMessages,
defineMessages,
NewModal,
useModalStack,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import {
announcementKey,
isAnnouncementActive,
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
parseAnnouncements,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
type RemoteAnnouncement,
safeAnnouncementUrl,
} from '@/helpers/remote-announcements'
const props = defineProps<{ ready: boolean; previewOnly?: boolean }>()
const { formatMessage } = useVIntl()
const { hasModal } = useModalStack()
const messages = defineMessages({
view: { id: 'app.remote-announcements.view', defaultMessage: 'View announcement' },
unread: { id: 'app.remote-announcements.unread', defaultMessage: 'Unread' },
readAll: {
id: 'app.remote-announcements.read-all',
defaultMessage: 'Mark all announcements as read',
},
centerTitle: { id: 'app.remote-announcements.center-title', defaultMessage: 'Announcements' },
empty: { id: 'app.remote-announcements.empty', defaultMessage: 'No announcements' },
previewTitle: {
id: 'app.remote-announcements.preview-title',
defaultMessage: 'Announcement style preview',
},
previewSummary: {
id: 'app.remote-announcements.preview-summary',
defaultMessage:
'This is a local preview. Select View announcement to preview the full Markdown content.',
},
previewContent: {
id: 'app.remote-announcements.preview-content',
defaultMessage:
'## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://skin.starlight.cool/)',
},
previewAction: { id: 'app.remote-announcements.preview-action', defaultMessage: 'Visit website' },
})
const modal = ref<InstanceType<typeof NewModal>>()
const selected = ref<RemoteAnnouncement | null>(null)
const active = ref(false)
const centerOpen = ref(false)
const startupNotice = ref<RemoteAnnouncement | null>(null)
const html = computed(() => renderString(selected.value?.content ?? ''))
const stateKey = 'starlight-remote-announcements-v2'
const read = reactive(new Set<string>())
const queuedThisSession = new Set<string>()
const startupNotified = new Set<string>()
let items: RemoteAnnouncement[] = []
let pending: RemoteAnnouncement[] = []
let cacheKey = ''
let cacheLoaded = false
let disposed = false
let interval: ReturnType<typeof setInterval> | undefined
let advanceTimer: ReturnType<typeof setTimeout> | undefined
function persist() {
if (props.previewOnly) return
try {
localStorage.setItem(
stateKey,
JSON.stringify({
read: [...read].slice(-1000),
}),
)
} catch {
// localStorage may be unavailable (private mode / quota)
}
}
function emitState() {
window.dispatchEvent(
new CustomEvent(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, {
detail: {
items,
unreadKeys: items.filter((item) => !read.has(announcementKey(item))).map(announcementKey),
},
}),
)
}
function openCenter(item?: RemoteAnnouncement) {
if (!props.ready || disposed) return
centerOpen.value = true
if (item) {
startupNotice.value = null
selectAnnouncement(item)
} else if (!selected.value && items.length) {
selectAnnouncement(items[0])
}
void nextTick(() => modal.value?.show())
}
function handleOpenCenter() {
if (props.previewOnly) return
openCenter()
}
async function show(item: RemoteAnnouncement) {
if (
disposed ||
!props.ready ||
!isAnnouncementActive(item) ||
(hasModal.value && !active.value && !centerOpen.value)
)
return
centerOpen.value = true
active.value = true
selectAnnouncement(item)
await nextTick()
if (!disposed) modal.value?.show()
}
function selectAnnouncement(item: RemoteAnnouncement) {
selected.value = item
const key = announcementKey(item)
read.add(key)
pending = pending.filter((entry) => announcementKey(entry) !== key)
persist()
emitState()
}
function markAllRead() {
for (const item of items) {
read.add(announcementKey(item))
}
pending = []
persist()
emitState()
}
function advance() {
if (disposed || !props.ready || hasModal.value || active.value) return
const next = pending.shift()
if (next) {
void show(next)
return
}
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'notification' &&
!startupNotified.has(key) &&
!read.has(key) &&
isAnnouncementActive(item)
) {
startupNotice.value = item
startupNotified.add(key)
setTimeout(() => {
if (startupNotice.value === item) {
startupNotice.value = null
advance()
}
}, 8000)
break
}
}
}
function closed() {
active.value = false
centerOpen.value = false
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
}
function sync(next: RemoteAnnouncement[], fresh: boolean) {
items = next.filter((item) => isAnnouncementActive(item))
const keys = new Set(items.map(announcementKey))
pending = pending.filter((item) => keys.has(announcementKey(item)))
if (selected.value && !keys.has(announcementKey(selected.value))) selected.value = null
emitState()
if (fresh) {
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'modal' &&
!queuedThisSession.has(key) &&
(!read.has(key) || item.priority === 'critical')
) {
queuedThisSession.add(key)
pending.push(item)
}
}
advance()
}
}
function loadCache() {
if (cacheLoaded || !cacheKey) return
cacheLoaded = true
try {
const cached = JSON.parse(localStorage.getItem(cacheKey) ?? 'null')
if (cached && typeof cached.savedAt === 'number' && Date.now() - cached.savedAt < 86400000) {
const parsed = parseAnnouncements(cached.items)
if (parsed) sync(parsed, false)
}
} catch {
// Ignore malformed or expired cache payloads
}
}
async function refresh() {
// Remote fetching is disabled until Starlight has its own announcements API.
// We still load any previously cached items so the center has content to show.
if (disposed) return
loadCache()
}
async function openLink(value: unknown) {
const url = safeAnnouncementUrl(value)
if (!url) return
try {
await openUrl(url)
} catch {
// Opener may reject unknown schemes
}
}
function contentClick(event: MouseEvent) {
const link = event.target instanceof Element ? event.target.closest('a') : null
if (!link) return
event.preventDefault()
event.stopPropagation()
void openLink(link.getAttribute('href'))
}
function preview(type: RemoteAnnouncement['type'], withAction = false) {
if (!props.previewOnly || !props.ready || hasModal.value || disposed) return
const now = new Date().toISOString()
const item: RemoteAnnouncement = {
id: 'local-preview-' + type,
title: formatMessage(messages.previewTitle),
summary: formatMessage(messages.previewSummary),
content: formatMessage(messages.previewContent),
type,
priority: 'normal',
starts_at: now,
ends_at: null,
published_at: now,
action_label: withAction ? formatMessage(messages.previewAction) : null,
action_url: withAction ? 'https://skin.starlight.cool/' : null,
}
read.clear()
queuedThisSession.clear()
sync([item], true)
}
defineExpose({ preview })
onMounted(() => {
if (props.previewOnly) return
try {
const saved = JSON.parse(localStorage.getItem(stateKey) ?? 'null')
if (saved && Array.isArray(saved.read))
for (const key of saved.read) if (typeof key === 'string') read.add(key)
} catch {
// Ignore malformed local read-state
}
window.addEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
const start = () => {
if (disposed) return
void refresh()
interval = setInterval(() => {
sync(items, false)
advance()
}, 15000)
}
// Defer the startup request until after first paint.
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(start, { timeout: 2000 })
} else {
setTimeout(start, 500)
}
})
watch([() => props.ready, hasModal], () => {
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
})
onUnmounted(() => {
disposed = true
if (interval) clearInterval(interval)
if (advanceTimer) clearTimeout(advanceTimer)
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
})
</script>
<template>
<div
v-if="startupNotice"
class="fixed right-4 top-16 z-50 w-[22rem] max-w-[calc(100vw-2rem)] rounded-xl border border-surface-5 bg-surface-3 p-3 shadow-lg"
>
<button class="w-full text-left" @click="startupNotice && openCenter(startupNotice)">
<div class="mb-1 text-xs font-semibold uppercase text-brand">
{{ formatMessage(messages.unread) }}
</div>
<div class="font-semibold text-[var(--color-text-primary)]">{{ startupNotice.title }}</div>
<div
v-if="startupNotice.summary"
class="mt-1 line-clamp-2 text-sm text-[var(--color-text-tertiary)]"
>
{{ startupNotice.summary }}
</div>
</button>
</div>
<NewModal
ref="modal"
:header="formatMessage(messages.centerTitle)"
:on-hide="closed"
max-width="80vw"
width="80vw"
max-content-height="calc(80vh - 8rem)"
scrollable
>
<div class="flex h-[calc(80vh-8rem)] min-h-0 gap-3">
<aside class="flex min-h-0 w-[32%] min-w-[14rem] flex-col border-r border-surface-5 pr-3">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.centerTitle)
}}</span>
<Button
v-if="items.some((item) => !read.has(announcementKey(item)))"
@click="markAllRead"
>{{ formatMessage(messages.readAll) }}</Button
>
</div>
<div
v-if="!items.length"
class="py-8 text-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
<div v-else class="flex min-h-0 flex-col gap-1 overflow-auto">
<button
v-for="item in items"
:key="announcementKey(item)"
class="flex items-start gap-2 rounded-lg p-2 text-left hover:bg-surface-4"
:class="{
'bg-surface-4': selected && announcementKey(selected) === announcementKey(item),
}"
@click="selectAnnouncement(item)"
>
<span
class="mt-1.5 size-2 shrink-0 rounded-full"
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
item.title
}}</span>
<span class="block line-clamp-2 text-xs text-[var(--color-text-tertiary)]">{{
item.summary || item.content
}}</span>
</span>
</button>
</div>
</aside>
<section v-if="selected" class="min-h-0 min-w-0 flex-1 overflow-auto px-1">
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
{{ selected.title }}
</h2>
<div
class="markdown-body break-words"
@click="contentClick"
@auxclick="contentClick"
v-html="html"
/>
<div class="mt-4 flex justify-end">
<Button
v-if="selected.action_url && selected.action_label"
type="colored"
color="brand"
@click="openLink(selected.action_url)"
>{{ selected.action_label }}</Button
>
</div>
</section>
<div
v-else
class="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<Button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }} </Button>
</div>
</template>
</NewModal>
</template>
@@ -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>
@@ -170,7 +170,7 @@ const {
top: auto !important;
right: 0;
bottom: 0;
left: 0 !important;
left: var(--left-bar-width, 4rem) !important;
z-index: 2;
width: auto;
min-height: 0;
@@ -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',
@@ -276,6 +312,48 @@ export const onboardingMessages = defineMessages({
},
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
homeIntroTitle: { id: 'app.onboarding.home-intro.title', defaultMessage: 'Welcome home' },
homeIntroDescription: {
id: 'app.onboarding.home-intro.description',
defaultMessage:
'This is your home page. It shows your instances, recent activity, and quick shortcuts to get playing.',
},
accountLoginTitle: {
id: 'app.onboarding.account-login.title',
defaultMessage: 'Sign in to StarLight',
},
accountLoginDescription: {
id: 'app.onboarding.account-login.description',
defaultMessage:
'Sign in to the StarLight skin site here to use your StarLight players, install hosted modpacks, and sync your account.',
},
announcementTitle: {
id: 'app.onboarding.announcement.title',
defaultMessage: 'Latest announcements',
},
announcementDescription: {
id: 'app.onboarding.announcement.description',
defaultMessage:
'Open the announcement entry in the sidebar to read the latest news, rules, and server notices.',
},
createStarlightTitle: {
id: 'app.onboarding.create-starlight.title',
defaultMessage: 'Create a StarLight instance',
},
createStarlightDescription: {
id: 'app.onboarding.create-starlight.description',
defaultMessage:
'Use Create new instance here. A StarLight instance installs the server-selected modpack and versions, then keeps them updated before each launch.',
},
settingsUpdatesTitle: {
id: 'app.onboarding.settings-updates.title',
defaultMessage: 'Stay up to date',
},
settingsUpdatesDescription: {
id: 'app.onboarding.settings-updates.description',
defaultMessage:
'The Updates tab controls the update channel, checks for new launcher versions, and shows the changelog.',
},
})
const step = (
@@ -331,20 +409,33 @@ const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescri
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
main: [
step(
'welcome',
'manual',
copy(
onboardingMessages.welcomeTitle,
onboardingMessages.welcomeDescription,
onboardingMessages.start,
),
inspect(
'home-intro',
'home-widget-grid',
onboardingMessages.homeIntroTitle,
onboardingMessages.homeIntroDescription,
),
inspect(
'home-layout-switch',
'home-layout-switch',
onboardingMessages.homeLayoutTitle,
onboardingMessages.homeLayoutDescription,
'account-login',
'sidebar-accounts',
onboardingMessages.accountLoginTitle,
onboardingMessages.accountLoginDescription,
),
inspect(
'announcement',
'home-announcement',
onboardingMessages.announcementTitle,
onboardingMessages.announcementDescription,
),
step(
'create-starlight',
'navigate',
copy(
onboardingMessages.createStarlightTitle,
onboardingMessages.createStarlightDescription,
onboardingMessages.clickCreate,
),
control('create-instance', '/create'),
),
step(
'discover-navigation',
@@ -354,7 +445,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.discoverDescription,
onboardingMessages.clickDiscover,
),
control('nav-discover', '/browse/modpack'),
control('nav-discover', '/browse'),
),
inspect(
'discover-content',
@@ -362,22 +453,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.browseTitle,
onboardingMessages.browseDescription,
),
step(
'discover-favorites-navigation',
'navigate',
copy(
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
onboardingMessages.clickFavorites,
),
control('browse-favorites-tab', '/browse/favorites'),
),
inspect(
'discover-favorites-content',
'browse-favorites-content',
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
),
step(
'skins-navigation',
'navigate',
@@ -394,6 +469,22 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.skinsPageTitle,
onboardingMessages.skinsPageDescription,
),
step(
'library-navigation',
'navigate',
copy(
onboardingMessages.libraryTitle,
onboardingMessages.libraryDescription,
onboardingMessages.clickLibrary,
),
control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
),
step(
'lab-navigation',
'navigate',
@@ -428,7 +519,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
),
step(
'settings-navigation',
'activate',
'navigate',
copy(
onboardingMessages.settingsTitle,
onboardingMessages.settingsDescription,
@@ -436,95 +527,15 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
),
control('nav-settings', '/settings'),
),
...settingsTourSteps.map(([id, targetId, title, description], index) =>
step(id, 'activate', copy(title, description, onboardingMessages.clickTab), {
...control(targetId),
closeSettingsAfter: index === settingsTourSteps.length - 1,
}),
),
step(
'library-navigation',
'navigate',
copy(
onboardingMessages.libraryTitle,
onboardingMessages.libraryDescription,
onboardingMessages.clickLibrary,
),
control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
),
step(
'create-instance',
'navigate',
copy(
onboardingMessages.createTitle,
onboardingMessages.createDescription,
onboardingMessages.clickCreate,
),
control('create-instance', '/create'),
),
inspect(
'creation-instance-mode',
'creation-instance-mode',
onboardingMessages.instanceModeTitle,
onboardingMessages.instanceModeDescription,
),
step(
'creation-flow',
'settings-updates',
'activate',
copy(
onboardingMessages.creationTitle,
onboardingMessages.creationDescription,
onboardingMessages.clickCreationMethod,
onboardingMessages.settingsUpdatesTitle,
onboardingMessages.settingsUpdatesDescription,
onboardingMessages.clickTab,
),
{
targetId: 'creation-methods',
branchByTarget: {
'creation-method-starlight': { next: 'complete' },
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
'creation-method-import': { next: 'complete' },
},
},
),
inspect(
'creation-name',
'creation-name',
onboardingMessages.creationNameTitle,
onboardingMessages.creationNameDescription,
),
inspect(
'creation-loader',
'creation-loader',
onboardingMessages.creationLoaderTitle,
onboardingMessages.creationLoaderDescription,
),
step(
'creation-version',
'inspect',
copy(
onboardingMessages.creationVersionTitle,
onboardingMessages.creationVersionDescription,
onboardingMessages.continueArea,
),
{
targetId: 'creation-game-version',
nextByCreationPath: { custom: 'creation-confirm' },
},
),
step(
'creation-confirm',
'inspect',
copy(
onboardingMessages.creationConfirmTitle,
onboardingMessages.creationConfirmDescription,
onboardingMessages.finishArea,
),
{ targetId: 'creation-confirm' },
control('settings-tab-updates'),
),
],
instance: [
@@ -534,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,
),
],
}
@@ -146,6 +146,14 @@ export function useOnboardingTour(
function scheduleMissingTargetRetry(stepId: string) {
if (targetRetryCount >= missingTargetRetryLimit) {
// 超过重试上限:inspect 步(介绍性步骤)不自动跳过,
// 保持无锚点状态,让气泡停靠底部等用户手动继续。
if (step.value.interaction === 'inspect') {
targetElement.value = undefined
targetRect.value = null
clearModalReservation()
return
}
void advance()
return
}
@@ -271,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()
@@ -280,11 +291,14 @@ 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
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
if (step.value.expectedPath && !route.path.startsWith(step.value.expectedPath)) {
waitingForRoute.value = true
return
}
@@ -301,7 +315,7 @@ export function useOnboardingTour(
() => route.path,
(path) => {
if (!waitingForRoute.value || !step.value.expectedPath) return
if (path !== step.value.expectedPath) return
if (!path.startsWith(step.value.expectedPath)) return
waitingForRoute.value = false
void advance()
},
@@ -0,0 +1,289 @@
<script lang="ts"></script>
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
import {
defineMessages,
IconButton,
useDebugLogger,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
const props = defineProps<{
screenshot: InstanceScreenshot
selectionKey: string
selected: boolean
selectionActive: boolean
activeDragged: boolean
canDrag: boolean
showInstanceName: boolean
highlighted: boolean
copied: boolean
}>()
const emit = defineEmits<{
(e: 'activate', event: MouseEvent | KeyboardEvent): void
(e: 'copy' | 'edit' | 'toggle-selection'): void
(e: 'more', event: MouseEvent): void
}>()
const card = ref<HTMLElement>()
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const image = ref<HTMLImageElement>()
const imageReady = ref(false)
const { formatMessage } = useVIntl()
const debugImage = useDebugLogger('Screenshots:Card')
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
let loadStartedAt = performance.now()
let loadGeneration = 0
const messages = defineMessages({
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
})
const sensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
useDraggable({
id: computed(() => `screenshot:${props.selectionKey}`),
element: card,
disabled: computed(() => !props.canDrag),
sensors,
data: computed(() => ({
selectionKey: props.selectionKey,
instanceId: props.screenshot.instance_id,
})),
})
function activate(event: MouseEvent | KeyboardEvent) {
if (event instanceof KeyboardEvent) {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
return
}
event.preventDefault()
}
emit('activate', event)
}
async function markImageLoaded() {
const loadedImage = image.value
const loadedUrl = thumbnail.value
const generation = loadGeneration
if (!loadedImage) return
try {
await loadedImage.decode()
} catch {
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
}
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
})
if (
generation !== loadGeneration ||
image.value !== loadedImage ||
thumbnail.value !== loadedUrl
) {
return
}
const wasReady = imageReady.value
imageReady.value = true
if (!wasReady) {
debugImage('image loaded', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
naturalWidth: image.value?.naturalWidth,
naturalHeight: image.value?.naturalHeight,
})
}
}
function markImageFailed(event: Event) {
debugImage('image failed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
event,
})
}
onMounted(() => {
debugImage('mounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
complete: image.value?.complete,
})
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
})
onBeforeUnmount(() => {
loadGeneration += 1
debugImage('unmounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loaded: imageReady.value,
})
})
watch(thumbnail, (url, previousUrl) => {
loadGeneration += 1
loadStartedAt = performance.now()
imageReady.value = false
debugImage('source changed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
previousUrl,
url,
})
})
</script>
<template>
<article
ref="card"
role="button"
tabindex="0"
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-brand brightness-110': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
'opacity-50': activeDragged,
'cursor-grab active:cursor-grabbing': canDrag,
}"
data-screenshot-card
:data-screenshot-id="screenshot.id"
:data-selection-key="selectionKey"
:aria-label="
selectionActive
? formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
: screenshot.file_name
"
:aria-pressed="selectionActive ? selected : undefined"
@click="activate"
@contextmenu.prevent.stop="emit('more', $event)"
@keydown="activate"
>
<button
type="button"
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
:aria-label="
formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
"
:aria-pressed="selected"
@click.stop="emit('toggle-selection')"
>
<span
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
:class="
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-brand" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
<img
v-if="thumbnail"
ref="image"
:src="thumbnail"
:alt="screenshot.file_name"
loading="eager"
decoding="async"
draggable="false"
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
:class="imageReady ? 'opacity-100' : 'opacity-0'"
@load="markImageLoaded"
@error="markImageFailed"
/>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-surface-4"
:class="{ 'animate-pulse': !imageReady }"
/>
<div
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-[var(--color-text-primary)] opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
>
<div class="min-w-0">
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
{{ screenshot.file_name }}
</div>
<div class="truncate text-xs text-[var(--color-text-tertiary)]">
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
</div>
</div>
<div
v-if="!selectionActive"
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
@click.stop
>
<IconButton
v-tooltip="formatMessage(messages.edit)"
:label="formatMessage(messages.edit)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('edit')"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
:label="formatMessage(copied ? messages.copied : messages.copy)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('copy')"
>
<CheckIcon v-if="copied" class="text-green" />
<ClipboardCopyIcon v-else />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moreActions)"
:label="formatMessage(messages.moreActions)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('more', $event)"
>
<MoreHorizontalIcon />
</IconButton>
</div>
</div>
</article>
</template>
<style scoped>
.screenshot-card-fade {
transition: opacity 350ms ease-in-out;
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ScreenshotDragPreview from './drag-preview.vue'
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
const props = defineProps<{
items: ScreenshotDragGatherItem[]
target: {
x: number
y: number
}
}>()
const emit = defineEmits<{
complete: []
}>()
const gathered = ref(false)
const finishing = ref(false)
let animationFrame: number | undefined
let completionTimer: number | undefined
const itemContainerStyles = computed(() =>
props.items.map((_, index) => ({
left: `${props.target.x}px`,
top: `${props.target.y}px`,
zIndex: `${index}`,
})),
)
const itemStyles = computed(() =>
props.items.map((item, index) => {
const originX = item.rect.left - props.target.x
const originY = item.rect.top - props.target.y
return {
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transform: gathered.value
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
: `translate3d(${originX}px, ${originY}px, 0)`,
transitionDuration: `${gatherDuration}ms`,
transitionDelay: `${Math.min(index, 8) * 10}ms`,
}
}),
)
onMounted(() => {
animationFrame = requestAnimationFrame(() => {
animationFrame = requestAnimationFrame(() => {
gathered.value = true
completionTimer = window.setTimeout(
() => {
finishing.value = true
emit('complete')
},
Math.max(gatherDuration - 250, 0),
)
})
})
})
onBeforeUnmount(() => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
if (completionTimer !== undefined) {
clearTimeout(completionTimer)
}
})
</script>
<template>
<Teleport to="body">
<div
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
:class="finishing ? 'opacity-0' : 'opacity-100'"
aria-hidden="true"
>
<div
v-for="(item, index) in items"
:key="item.selectionKey"
class="fixed"
:style="itemContainerStyles[index]"
>
<div
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
:style="itemStyles[index]"
>
<ScreenshotDragPreview :screenshot="item.screenshot" />
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { TagItem } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
import { gatherDuration } from './use-screenshot-drag-gather'
const props = withDefaults(
defineProps<{
screenshot: InstanceScreenshot
count?: number
}>(),
{
count: 1,
},
)
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const showGatheredCount = ref(false)
let countTimer: number | undefined
onMounted(() => {
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showGatheredCount.value = true
return
}
countTimer = window.setTimeout(() => {
showGatheredCount.value = true
countTimer = undefined
}, gatherDuration - 150)
})
onBeforeUnmount(() => {
if (countTimer !== undefined) {
clearTimeout(countTimer)
}
})
</script>
<template>
<div aria-hidden="true" class="relative w-full select-none">
<div
v-if="count > 1"
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
/>
<div
v-if="count > 1"
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
/>
<div
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
>
<img v-if="thumbnail" :src="thumbnail" alt="" class="h-full w-full object-cover" />
<TagItem
v-if="count > 1"
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-[var(--color-text-tertiary)] motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
>
{{ count }}
</TagItem>
</div>
</div>
</template>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
import ScreenshotCard from './card.vue'
import ScreenshotSection from './section.vue'
const props = defineProps<{
id: string
title: string
screenshots: InstanceScreenshot[]
renderedScreenshots?: InstanceScreenshot[]
virtualGridHeight?: number
virtualGridTop?: number
selectedKeys: ReadonlySet<string>
selectionActive: boolean
activeDraggedKeys: ReadonlySet<string>
showDropOutline: boolean
canDrag: boolean
dropInstanceId?: string
dropCustomGroup?: boolean
dropCustomGroupId?: string
showInstanceName: boolean
highlightedScreenshotId?: string
copiedScreenshotIds: ReadonlySet<string>
forceOpen: boolean
hideHeader?: boolean
editableTitle?: boolean
startEditingTitle?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>()
const collapsed = defineModel<boolean>('collapsed', { required: true })
const dropTarget = ref<HTMLElement>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Group')
const messages = defineMessages({
emptyGroup: {
id: 'app.screenshots.group.empty',
defaultMessage: 'Drag and drop to add screenshots.',
},
})
const shouldShowGrid = computed(() =>
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
)
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
const renderGrid = ref(shouldShowGrid.value)
const virtualGridStyle = computed(() =>
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
)
const visibleGridStyle = computed(() =>
props.virtualGridTop === undefined
? undefined
: { transform: `translateY(${props.virtualGridTop}px)` },
)
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
let resizeObserver: ResizeObserver | undefined
async function logGeometry(reason: string) {
await nextTick()
const rect = dropTarget.value?.getBoundingClientRect()
debugLayout(reason, {
id: props.id,
title: props.title,
collapsed: collapsed.value,
shouldShowGrid: shouldShowGrid.value,
renderGrid: renderGrid.value,
screenshotCount: props.screenshots.length,
renderedCount: visibleScreenshots.value.length,
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
virtualGridHeight: props.virtualGridHeight,
virtualGridTop: props.virtualGridTop,
actual: rect
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
: undefined,
})
}
watch(
() => props.renderedScreenshots ?? props.screenshots,
(screenshots) => {
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
void logGeometry('rendered screenshots changed')
},
)
watch(
() => [props.virtualGridHeight, props.virtualGridTop] as const,
() => void logGeometry('virtual grid geometry changed'),
)
watch(
shouldShowGrid,
(showGrid, previouslyShown) => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
if (showGrid) {
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
renderGrid.value = true
void logGeometry('grid shown')
return
}
if (!previouslyShown) {
renderGrid.value = false
return
}
unmountGridTimeout = setTimeout(() => {
renderGrid.value = false
void logGeometry('grid unmounted after collapse')
unmountGridTimeout = undefined
}, 300)
},
{ flush: 'post' },
)
onMounted(() => {
debugLayout('mounted', { id: props.id, title: props.title })
void logGeometry('mounted geometry')
if (dropTarget.value) {
resizeObserver = new ResizeObserver(([entry]) => {
const rect = entry?.target.getBoundingClientRect()
debugLayout('actual size changed', {
id: props.id,
title: props.title,
width: rect?.width,
height: rect?.height,
expectedHeight:
(props.hideHeader ? 0 : 40) +
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
12,
})
})
resizeObserver.observe(dropTarget.value)
}
})
onBeforeUnmount(() => {
debugLayout('unmounted', {
id: props.id,
title: props.title,
renderedCount: visibleScreenshots.value.length,
})
resizeObserver?.disconnect()
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
})
const emit = defineEmits<{
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
}>()
useDroppable({
id: computed(() => `screenshot-group:${props.id}`),
element: dropTarget,
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
data: computed(() =>
props.dropCustomGroup
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
: { groupId: props.id, instanceId: props.dropInstanceId },
),
})
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
</script>
<template>
<div
ref="dropTarget"
class="group/instance-container relative select-none pb-3 transition-colors"
>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="!opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="!opacity-0"
>
<div
v-if="showDropOutline"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-brand bg-brand-highlight opacity-40"
/>
</Transition>
<ScreenshotSection
v-model:collapsed="collapsed"
:title="title"
:count="screenshots.length"
:force-open="forceOpen"
:hide-header="hideHeader"
:editable="editableTitle"
:start-editing="startEditingTitle"
:max-title-length="maxTitleLength"
:validate-title="validateTitle"
:on-title-change="onTitleChange"
>
<template #actions="{ startEditing }">
<slot name="actions" :start-editing="startEditing" />
</template>
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
<div
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
:style="visibleGridStyle"
>
<ScreenshotCard
v-for="screenshot in visibleScreenshots"
:key="getSelectionKey(screenshot)"
:screenshot="screenshot"
:selection-key="getSelectionKey(screenshot)"
:selected="selectedKeys.has(getSelectionKey(screenshot))"
:selection-active="selectionActive"
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
:can-drag="canDrag"
:show-instance-name="showInstanceName"
:highlighted="highlightedScreenshotId === screenshot.id"
:copied="copiedScreenshotIds.has(screenshot.id)"
@activate="(event) => emit('activate', screenshot, event)"
@toggle-selection="emit('toggle-selection', screenshot)"
@copy="emit('copy', screenshot)"
@edit="emit('edit', screenshot)"
@more="(event) => emit('more', screenshot, event)"
/>
<p
v-if="screenshots.length === 0"
key="empty-group"
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</div>
</div>
</ScreenshotSection>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import {
Accordion,
commonMessages,
defineMessages,
InlineEditableText,
TagItem,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
title: string
count: number
collapsed?: boolean
forceOpen?: boolean
hideHeader?: boolean
editable?: boolean
startEditing?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>(),
{
collapsed: false,
forceOpen: false,
hideHeader: false,
editable: false,
startEditing: false,
},
)
const emit = defineEmits<{
(e: 'update:collapsed', collapsed: boolean): void
}>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Section')
const accordion = ref<InstanceType<typeof Accordion>>()
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
const titleModel = ref(props.title)
const messages = defineMessages({
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
})
function toggle() {
debugLayout('toggle requested', {
title: props.title,
count: props.count,
isOpen: accordion.value?.isOpen,
})
if (accordion.value?.isOpen) {
accordion.value.close()
} else {
accordion.value?.open()
}
}
function handleOpen() {
debugLayout('opened', { title: props.title, count: props.count })
emit('update:collapsed', false)
}
function handleClose() {
debugLayout('closed', { title: props.title, count: props.count })
emit('update:collapsed', true)
}
onMounted(() => {
debugLayout('mounted', {
title: props.title,
count: props.count,
collapsed: props.collapsed,
hideHeader: props.hideHeader,
forceOpen: props.forceOpen,
})
})
onBeforeUnmount(() => {
debugLayout('unmounted', { title: props.title, count: props.count })
})
async function startTitleEditing() {
if (!props.editable) return
await titleInput.value?.startEditing()
}
watch(
() => props.title,
(title) => {
titleModel.value = title
},
)
watch(
() => props.startEditing,
async (shouldStartEditing) => {
if (!shouldStartEditing) return
await nextTick()
await startTitleEditing()
},
{ immediate: true, flush: 'post' },
)
</script>
<template>
<section class="flex w-full flex-col">
<div
v-if="!hideHeader"
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
>
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
<button
type="button"
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
:aria-expanded="accordion?.isOpen"
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
@click.stop="toggle"
>
<DropdownIcon
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-all duration-300 group-hover/open-target:text-[var(--color-text-default)]"
:class="{ 'rotate-180': accordion?.isOpen }"
/>
</button>
<InlineEditableText
v-if="editable"
ref="titleInput"
v-model="titleModel"
activation-mode="manual"
class="!h-10 select-none text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
:edit-label="formatMessage(commonMessages.renameButton)"
max-width="24rem"
icon-text-class="select-none"
:max-length="maxTitleLength"
:validate="validateTitle"
:on-change="onTitleChange"
/>
<span
v-else
class="select-none truncate text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
>
{{ title }}
</span>
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
{{ count }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<slot name="actions" :start-editing="startTitleEditing" />
</div>
<Accordion
ref="accordion"
:open-by-default="hideHeader || !props.collapsed"
:force-open="forceOpen"
overflow-visible
class="w-full"
@on-open="handleOpen"
@on-close="handleClose"
>
<div class="mt-2.5">
<slot />
</div>
</Accordion>
</section>
</template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ArrowUpDownIcon, GridIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Button,
Combobox,
type ComboboxOption,
defineMessages,
StyledInput,
useVIntl,
} from '@modrinth/ui'
const search = defineModel<string>('search', { required: true })
const sort = defineModel<string>('sort', { required: true })
const group = defineModel<string>('group', { required: true })
defineProps<{
sortOptions: ComboboxOption<string>[]
groupOptions: ComboboxOption<string>[]
}>()
const emit = defineEmits<{
(e: 'new-group'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
})
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="min-w-[16rem] flex-1"
/>
<Button @click="emit('new-group')">
<PlusIcon />
{{ formatMessage(messages.newGroup) }}
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<Combobox
v-model="sort"
class="w-max"
:options="sortOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<ArrowUpDownIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.sortBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
<Combobox
v-model="group"
class="w-max"
:options="groupOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<GridIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.groupBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
</div>
</div>
</template>
@@ -0,0 +1,130 @@
import { onBeforeUnmount, type Ref, ref } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
type Point = {
x: number
y: number
}
export type ActiveScreenshotDrag = {
primarySelectionKey: string
selectionKeys: string[]
}
export const gatherDuration = 500
export type ScreenshotDragGatherItem = {
screenshot: InstanceScreenshot
selectionKey: string
rect: {
left: number
top: number
width: number
height: number
}
}
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
export function useScreenshotDragGather(screenshots: Ref<InstanceScreenshot[]>) {
const items = ref<ScreenshotDragGatherItem[]>([])
const target = ref<Point>({ x: 0, y: 0 })
const targetOffset = ref<Point>({ x: 0, y: 0 })
const isGathering = ref(false)
let cleanupTimer: number | undefined
const clearCleanupTimer = () => {
if (cleanupTimer !== undefined) {
clearTimeout(cleanupTimer)
cleanupTimer = undefined
}
}
const clear = () => {
clearCleanupTimer()
items.value = []
targetOffset.value = { x: 0, y: 0 }
isGathering.value = false
}
const updateTarget = (pointer: Point) => {
target.value = {
x: pointer.x + targetOffset.value.x,
y: pointer.y + targetOffset.value.y,
}
}
const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => {
clear()
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return
const screenshotCards = Array.from(
document.querySelectorAll<HTMLElement>('[data-screenshot-card]'),
)
const sourceCard = screenshotCards.find(
(card) => card.dataset.selectionKey === drag.primarySelectionKey,
)
if (sourceCard) {
const sourceRect = sourceCard.getBoundingClientRect()
targetOffset.value = {
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
}
}
const screenshotsByKey = new Map(
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
)
items.value = drag.selectionKeys.flatMap((selectionKey) => {
const screenshot = screenshotsByKey.get(selectionKey)
const card = screenshotCards.find(
(candidate) =>
candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0,
)
if (!screenshot || !card) return []
const rect = card.getBoundingClientRect()
return [
{
screenshot,
selectionKey,
rect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
},
]
})
updateTarget(pointer)
isGathering.value = items.value.length > 0
}
const finish = () => {
isGathering.value = false
clearCleanupTimer()
cleanupTimer = window.setTimeout(() => {
items.value = []
cleanupTimer = undefined
}, 400)
}
onBeforeUnmount(clearCleanupTimer)
return {
items,
target,
isGathering,
start,
updateTarget,
clear,
finish,
}
}
@@ -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,84 @@ 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)
settings.value.ui_scale = clamped
}
const accentColorOptions: Array<{
value: AccentColor
color: string
@@ -483,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,
@@ -493,6 +643,8 @@ watch(
transparentBlur,
sidebarInstanceCount,
closeBehavior,
uiFont,
monoFont,
]) => {
themeStore.customBackgroundPath = path
themeStore.customBackgroundBlur = blur
@@ -503,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 () => {
@@ -1050,7 +1211,7 @@ watch(
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
"
@click="setUiScale(preset)"
@click="updateUiScale(preset)"
>
{{ preset }}%
</button>
@@ -1062,7 +1223,7 @@ watch(
:max="200"
:step="5"
unit="%"
@update:model-value="(value) => setUiScale(Number(value))"
@update:model-value="(value) => updateUiScale(Number(value))"
/>
</div>
</template>
@@ -1142,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>
@@ -7,7 +7,7 @@ import {
Toggle,
useVIntl,
} from '@modrinth/ui'
import { inject, watch } from 'vue'
import { inject, ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { isDev } from '@/helpers/utils'
@@ -54,6 +54,26 @@ const messages = defineMessages({
},
})
const featureFlagLabels: Record<FeatureFlag, string> = {
project_background: '项目背景',
page_path: '页面路径',
worlds_tab: '世界标签页',
worlds_in_home: '主页显示世界',
server_project_qa: '服务器项目问答',
show_version_environment_column: '显示版本环境列',
server_ram_as_bytes_always_on: '服务器内存始终按字节显示',
always_show_app_controls: '始终显示窗口控件',
skip_non_essential_warnings: '跳过非必要警告',
skip_unknown_pack_warning: '跳过未知整合包警告',
i18n_debug: '国际化调试',
show_instance_play_time: '显示实例游玩时间',
page_transitions: '页面切换动画',
advanced_filters_collapsed: '高级筛选默认折叠',
multiplayer_in_app: '多人联机',
auto_install_dependencies: '自动安装依赖',
force_anniversary_celebration: '强制显示周年庆庆祝(调试)',
}
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
@@ -85,7 +105,7 @@ watch(
<template>
<SettingsSection>
<SettingsRow v-for="option in options" :key="option">
<template #label>{{ option.replaceAll('_', ' ') }}</template>
<template #label>{{ featureFlagLabels[option] ?? option }}</template>
<template #control>
<div class="flex items-center gap-2">
<Button
@@ -124,4 +144,4 @@ watch(
</Button>
</div>
</SettingsSection>
</template>
</template>
@@ -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,17 @@
import { onUnmounted } from 'vue'
import { instance_listener } from '@/helpers/events.js'
export interface InstanceAppEvent {
event: string
instance_id?: string
}
export function useAppEvent(type: 'instance', callback: (event: InstanceAppEvent) => void) {
if (type !== 'instance') return
let unlisten: (() => void) | undefined
void instance_listener(callback).then((dispose) => {
unlisten = dispose
})
onUnmounted(() => unlisten?.())
}
@@ -0,0 +1,154 @@
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
export type ImageThumbnailSource = {
instanceId: string
filePath: string
path: string
}
type ThumbnailCacheEntry = {
promise: Promise<string>
url?: string
users: number
lastUsed: number
}
const MAX_CONCURRENT_THUMBNAILS = 4
const MAX_CACHED_THUMBNAILS = 256
const thumbnailCache = new Map<string, ThumbnailCacheEntry>()
const thumbnailQueue: Array<() => void> = []
let activeThumbnailRequests = 0
function runThumbnailQueue() {
while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) {
activeThumbnailRequests += 1
thumbnailQueue.shift()?.()
}
}
function enqueueThumbnail<T>(task: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
thumbnailQueue.push(() => {
void task()
.then(resolve, reject)
.finally(() => {
activeThumbnailRequests -= 1
runThumbnailQueue()
})
})
runThumbnailQueue()
})
}
function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) {
if (thumbnailCache.get(cacheKey) !== entry) return
thumbnailCache.delete(cacheKey)
if (entry.url) {
URL.revokeObjectURL(entry.url)
} else {
void entry.promise.then(
(url) => URL.revokeObjectURL(url),
() => undefined,
)
}
}
function trimThumbnailCache() {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return
const unused = [...thumbnailCache.entries()]
.filter(([, entry]) => entry.users === 0)
.sort(([, left], [, right]) => left.lastUsed - right.lastUsed)
for (const [cacheKey, entry] of unused) {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break
removeThumbnail(cacheKey, entry)
}
}
function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) {
const cacheKey = JSON.stringify([
source.instanceId,
source.filePath,
maxDimension,
revision ?? null,
])
let entry = thumbnailCache.get(cacheKey)
if (!entry) {
entry = {
users: 0,
lastUsed: performance.now(),
promise: enqueueThumbnail(async () => {
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
instanceId: source.instanceId,
filePath: source.filePath,
maxDimension,
})
return URL.createObjectURL(new Blob([bytes]))
}),
}
thumbnailCache.set(cacheKey, entry)
void entry.promise.then(
(url) => {
entry!.url = url
trimThumbnailCache()
},
() => {
if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey)
},
)
}
entry.users += 1
entry.lastUsed = performance.now()
return {
promise: entry.promise,
release: () => {
entry!.users = Math.max(0, entry!.users - 1)
entry!.lastUsed = performance.now()
trimThumbnailCache()
},
}
}
export function useImageThumbnail(
source: MaybeRefOrGetter<ImageThumbnailSource | null | undefined>,
maxDimension: MaybeRefOrGetter<number> = 512,
revision: MaybeRefOrGetter<string | number | undefined> = undefined,
) {
const thumbnail = ref('')
watch(
[() => toValue(source), () => toValue(maxDimension), () => toValue(revision)],
([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => {
thumbnail.value = ''
if (!currentSource) return
let active = true
const fallbackUrl = convertFileSrc(currentSource.path)
const acquired = acquireThumbnail(
currentSource,
Math.max(1, Math.round(currentMaxDimension)),
currentRevision,
)
onCleanup(() => {
active = false
acquired.release()
})
void acquired.promise.then(
(url) => {
if (active) thumbnail.value = url
},
(error) => {
console.warn('Could not create image thumbnail', error)
if (active) thumbnail.value = fallbackUrl
},
)
},
{ immediate: true },
)
return thumbnail
}
@@ -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))
}
@@ -0,0 +1 @@
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
+186 -1
View File
@@ -9,7 +9,7 @@ import {
type ContentItem,
type ContentOwner,
} from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { isOfflineMode } from '@/composables/useNetworkStatus'
import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector'
@@ -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,
@@ -1124,3 +1222,90 @@ export async function check_symlink_capability(): Promise<SymlinkCapability> {
export async function allow_symlink_target(path: string): Promise<void> {
return await invoke('allow_symlink_target', { path })
}
export type ScreenshotKey = { instance_id: string; file_name: string }
export type InstanceScreenshot = {
id: string
instance_id: string
instance_name: string
file_name: string
created_at: string
modified_at: number
group_id: string | null
path: string
url: string
}
export type ScreenshotGroup = { id: string; name: string }
export type ScreenshotGroupImport = ScreenshotGroup & { screenshot_ids: string[] }
export type ScreenshotGroupMembershipUpdate = { screenshot_id: string; group_id: string | null }
export async function list_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
}
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_all_screenshots')
}
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_synced_screenshots')
}
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
return await invoke('plugin:instance|instance_list_screenshot_groups')
}
export async function create_screenshot_group(
name: string,
screenshotIds: string[],
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_create_screenshot_group', {
name,
ids: screenshotIds,
})
}
export async function rename_screenshot_group(
id: string,
newName: string,
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
}
export async function delete_screenshot_group(id: string): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
}
export async function set_screenshot_group_memberships(
updates: ScreenshotGroupMembershipUpdate[],
): Promise<void> {
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
}
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
return await invoke('plugin:instance|instance_import_screenshot_groups', { groups })
}
export async function save_edited_screenshot(
key: ScreenshotKey,
pngBytes: Uint8Array,
mode: 'create_copy' | 'replace_edit',
): Promise<InstanceScreenshot> {
return await invoke('plugin:instance|instance_save_edited_screenshot', {
key,
bytes: Array.from(pngBytes),
mode,
})
}
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
}
export async function export_screenshots(keys: ScreenshotKey[], path: string): Promise<void> {
return await invoke('plugin:instance|instance_export_screenshots', { keys, path })
}
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
return await invoke('plugin:instance|instance_open_screenshot', { key })
}
export async function move_screenshots(
keys: ScreenshotKey[],
targetInstanceId: string,
): Promise<ScreenshotKey[]> {
return await invoke('plugin:instance|instance_move_screenshots', {
keys,
target: targetInstanceId,
})
}
export function getInstanceIconUrl(iconPath?: string | null): string | undefined {
return iconPath ? convertFileSrc(iconPath) : undefined
}
@@ -64,3 +64,6 @@ export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now())
export function announcementKey(item: RemoteAnnouncement) {
return item.id + ':' + item.published_at
}
export const REMOTE_ANNOUNCEMENTS_UPDATED_EVENT = 'starlight:remote-announcements-updated'
export const OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT = 'starlight:open-remote-announcement-center'
@@ -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
@@ -172,6 +173,9 @@ export type AppSettings = {
home_layout: HomeLayout
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[]
@@ -213,6 +217,9 @@ 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' &&
@@ -239,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
+23 -29
View File
@@ -1,10 +1,11 @@
import { invoke } from '@tauri-apps/api/core'
import { ref, watch } from 'vue'
/**
* 启动器界面缩放(百分比)。
* 这是纯本地的显示偏好,用 localStorage 保存即可,无需写入后端设置数据库。
* 通过 Tauri 原生 webview zoom(Rust 侧 set_zoom)应用,
* 缩放值持久化到后端设置数据库(settings.ui_scale)。
*/
const UI_SCALE_STORAGE_KEY = 'starlight-ui-scale'
const MIN_UI_SCALE = 50
const MAX_UI_SCALE = 200
const DEFAULT_UI_SCALE = 100
@@ -15,18 +16,7 @@ function clampUiScale(value: number): number {
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Math.round(value)))
}
/** 从 localStorage 读取已保存的缩放值。 */
function readStoredUiScale(): number {
try {
const raw = localStorage.getItem(UI_SCALE_STORAGE_KEY)
if (raw === null) return DEFAULT_UI_SCALE
return clampUiScale(Number(raw))
} catch {
return DEFAULT_UI_SCALE
}
}
export const uiScale = ref(readStoredUiScale())
export const uiScale = ref(DEFAULT_UI_SCALE)
export function setUiScale(value: number): void {
uiScale.value = clampUiScale(value)
@@ -38,23 +28,27 @@ export function resetUiScale(): void {
export { DEFAULT_UI_SCALE, MAX_UI_SCALE, MIN_UI_SCALE }
/** 把缩放应用到文档根节点,并在变化时持久化。 */
export function installUiScale(): void {
const apply = (value: number) => {
const factor = String(value / 100)
const root = document.documentElement
root.style.setProperty('--ui-scale', factor)
// 用 CSS zoom 整体缩放界面(含内嵌网页),在 WebView2/WebKit 上最可靠。
root.style.setProperty('zoom', factor)
/**
* 把缩放值应用到原生 webview。
* 同时保留 --ui-scale CSS 变量,供依赖它的样式/锚点使用。
*/
async function applyUiScale(value: number): Promise<void> {
const factor = value / 100
document.documentElement.style.setProperty('--ui-scale', String(factor))
try {
await invoke('plugin:settings|set_ui_scale', { factor })
} catch (error) {
console.warn('应用界面缩放失败', error)
}
}
apply(uiScale.value)
/**
* 初始化缩放:应用当前值,并在变化时同步到原生 webview。
* 初值由调用方(App 初始化流程)在读取后端设置后通过 setUiScale 覆盖。
*/
export function installUiScale(): void {
void applyUiScale(uiScale.value)
watch(uiScale, (value) => {
apply(value)
try {
localStorage.setItem(UI_SCALE_STORAGE_KEY, String(value))
} catch (error) {
console.warn('保存界面缩放失败', error)
}
void applyUiScale(value)
})
}
File diff suppressed because it is too large. Load diff
+936 -2
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": "内容、文件、截图、世界和日志都在这些标签里。乱中有序。"
},
@@ -6061,6 +6136,36 @@
"app.onboarding.welcome.title": {
"message": "一切就绪"
},
"app.onboarding.home-intro.title": {
"message": "欢迎回家"
},
"app.onboarding.home-intro.description": {
"message": "这里是你的主页,展示你的实例、最近活动和快速开始游戏的入口。"
},
"app.onboarding.account-login.title": {
"message": "登录 StarLight"
},
"app.onboarding.account-login.description": {
"message": "在这里登录 StarLight 皮肤站,即可使用皮肤站玩家、安装托管整合包并同步你的账号。"
},
"app.onboarding.announcement.title": {
"message": "最新公告"
},
"app.onboarding.announcement.description": {
"message": "打开侧栏的公告入口,查看最新消息、服规与服务器通知。"
},
"app.onboarding.create-starlight.title": {
"message": "创建 StarLight 实例"
},
"app.onboarding.create-starlight.description": {
"message": "点击这里的“创建新实例”。StarLight 实例会安装服务器指定的整合包与版本,并在每次启动前保持更新。"
},
"app.onboarding.settings-updates.title": {
"message": "保持最新"
},
"app.onboarding.settings-updates.description": {
"message": "“更新”页用于设置更新通道、检查启动器新版本并查看更新日志。"
},
"app.project.curseforge.loading": {
"message": "正在加载 CurseForge 项目…"
},
@@ -7165,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": "应用数据库备份"
},
@@ -10428,5 +10539,828 @@
},
"app.settings.about.easteregg.game-title": {
"message": "彩蛋小游戏"
}
}
},
"app.home.launch-progress.empty": {
"message": "启动游戏后,这里会显示进度。"
},
"app.home.launch-progress.title": {
"message": "启动进度"
},
"app.screenshots.copied": {
"message": "已复制"
},
"app.screenshots.copy": {
"message": "复制图像"
},
"app.screenshots.delete-description": {
"message": "永久删除 {name}?此操作无法撤销。"
},
"app.screenshots.delete-success": {
"message": "截图已删除"
},
"app.screenshots.delete-title": {
"message": "删除截图"
},
"app.screenshots.deselect": {
"message": "取消选择 {name}"
},
"app.screenshots.edit": {
"message": "编辑截图"
},
"app.screenshots.empty-description": {
"message": "你在游戏中拍摄的截图会显示在这里。"
},
"app.screenshots.empty-heading": {
"message": "还没有截图"
},
"app.screenshots.error-heading": {
"message": "加载截图失败"
},
"app.screenshots.go-to-instance": {
"message": "前往实例"
},
"app.screenshots.group-by": {
"message": "分组方式"
},
"app.screenshots.group.all-screenshots": {
"message": "全部截图"
},
"app.screenshots.group.collapse": {
"message": "折叠分组"
},
"app.screenshots.group.custom": {
"message": "自定义分组"
},
"app.screenshots.group.date": {
"message": "日期"
},
"app.screenshots.group.delete": {
"message": "删除分组"
},
"app.screenshots.group.delete-description": {
"message": "此分组中的截图将变为未分组。"
},
"app.screenshots.group.edit": {
"message": "编辑分组名称"
},
"app.screenshots.group.empty": {
"message": "拖放以添加截图。"
},
"app.screenshots.group.expand": {
"message": "展开分组"
},
"app.screenshots.group.instance": {
"message": "实例"
},
"app.screenshots.group.new": {
"message": "新建分组"
},
"app.screenshots.group.none": {
"message": "不分组"
},
"app.screenshots.group.this-month": {
"message": "本月"
},
"app.screenshots.group.this-week": {
"message": "本周"
},
"app.screenshots.group.today": {
"message": "今天"
},
"app.screenshots.group.ungrouped": {
"message": "未分组"
},
"app.screenshots.group.yesterday": {
"message": "昨天"
},
"app.screenshots.heading": {
"message": "截图"
},
"app.screenshots.more-actions": {
"message": "更多操作"
},
"app.screenshots.no-results-description": {
"message": "请尝试其他搜索或筛选条件。"
},
"app.screenshots.no-results-heading": {
"message": "没有匹配的截图"
},
"app.screenshots.preview.instance-and-date": {
"message": "{instance} · {date}"
},
"app.screenshots.search": {
"message": "搜索"
},
"app.screenshots.select": {
"message": "选择 {name}"
},
"app.screenshots.selection.aria-label": {
"message": "已选中的截图"
},
"app.screenshots.selection.delete-description": {
"message": "删除 {count, plural, one {# 张截图} other {# 张截图}}?此操作无法撤销。"
},
"app.screenshots.selection.delete-success": {
"message": "{count, plural, one {已删除 # 张截图} other {已删除 # 张截图}}"
},
"app.screenshots.selection.delete-title": {
"message": "删除选中的截图"
},
"app.screenshots.selection.export-zip": {
"message": "导出 ZIP"
},
"app.screenshots.selection.global-export-filename": {
"message": "Modrinth screenshots.zip"
},
"app.screenshots.selection.instance-export-filename": {
"message": "{instance} screenshots.zip"
},
"app.screenshots.selection.remove-from-group": {
"message": "从分组中移除"
},
"app.screenshots.selection.selected-count": {
"message": "已选中 {count} 张"
},
"app.screenshots.selection.zip-archive": {
"message": "ZIP 压缩包"
},
"app.screenshots.show-in-folder": {
"message": "在文件夹中显示"
},
"app.screenshots.sort-by": {
"message": "排序方式"
},
"app.screenshots.sort.name": {
"message": "名称"
},
"app.screenshots.sort.newest": {
"message": "最新"
},
"app.screenshots.sort.oldest": {
"message": "最旧"
},
"app.settings.about.copyright": {
"message": "版权所有 © StarLight 保留所有权利。"
},
"app.settings.about.game.best": {
"message": "最佳:{score}"
},
"app.settings.about.game.best-label": {
"message": "最佳"
},
"app.settings.about.game.completed": {
"message": "下界之星!"
},
"app.settings.about.game.exit": {
"message": "退出游戏"
},
"app.settings.about.game.game-over": {
"message": "群星坠落!"
},
"app.settings.about.game.highest-level": {
"message": "最高:等级 {level}"
},
"app.settings.about.game.level-label": {
"message": "最高等级"
},
"app.settings.about.game.new-record": {
"message": "新纪录!"
},
"app.settings.about.game.overtime": {
"message": "涨潮!"
},
"app.settings.about.game.overtime-detail": {
"message": "潮水涌回——继续合成!"
},
"app.settings.about.game.overtimes-label": {
"message": "加时次数"
},
"app.settings.about.game.restart": {
"message": "重新开始"
},
"app.settings.about.game.score": {
"message": "得分:{score}"
},
"app.settings.about.game.score-label": {
"message": "得分"
},
"app.settings.about.game.settle": {
"message": "稳定"
},
"app.settings.about.game.settle-title": {
"message": "本局已稳定!"
},
"app.settings.about.game.tap-to-drop": {
"message": "点击投放一颗星"
},
"app.settings.about.game.tide-hint": {
"message": "天空正在坠落——继续合成!"
},
"app.settings.about.game.title": {
"message": "Starlight 合成"
},
"app.starlight-skin.frame-title": {
"message": "StarLight 皮肤站"
},
"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 {# 个快照}}。"
}
}
@@ -0,0 +1,11 @@
<script setup lang="ts">
import ScreenshotsPage from '@/components/ui/screenshots-page/index.vue'
defineOptions({ name: 'ScreenshotsPage' })
</script>
<template>
<div class="box-border h-full p-6">
<ScreenshotsPage show-heading />
</div>
</template>
+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,57 @@
import { queryOptions } from '@tanstack/vue-query'
import {
list,
list_all_screenshots,
list_screenshot_groups,
list_screenshots,
list_synced_screenshots,
} from '@/helpers/instance'
export const instanceKeys = {
all: ['instances'] as const,
list: () => ['instances', 'list'] as const,
}
export function instanceListQueryOptions() {
return queryOptions({
queryKey: instanceKeys.list(),
queryFn: list,
})
}
export const screenshotKeys = {
all: ['screenshots'] as const,
global: () => ['screenshots', 'global'] as const,
synced: () => ['screenshots', 'synced'] as const,
instance: (instanceId: string) => ['screenshots', 'instance', instanceId] as const,
groups: () => ['screenshots', 'groups'] as const,
}
export function instanceScreenshotsQueryOptions(instanceId: string) {
return queryOptions({
queryKey: screenshotKeys.instance(instanceId),
queryFn: () => list_screenshots(instanceId),
})
}
export function allScreenshotsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.global(),
queryFn: list_all_screenshots,
})
}
export function syncedScreenshotsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.synced(),
queryFn: list_synced_screenshots,
})
}
export function screenshotGroupsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.groups(),
queryFn: list_screenshot_groups,
})
}
@@ -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] =
+2
View File
@@ -3,6 +3,7 @@ import type { AbstractPopupNotificationManager, AbstractWebNotificationManager }
import { setupCreationModal } from './setup/creation-modal'
import { setupFileDropProvider } from './setup/file-drop'
import { setupFilePickerProvider } from './setup/file-picker'
import { setupImageViewerEditorProvider } from './setup/image-viewer-editor'
import { setupInstanceImportProvider } from './setup/instance-import'
import { setupTagsProvider } from './setup/tags'
@@ -14,6 +15,7 @@ export function setupProviders(
setupTagsProvider(notificationManager, stateInitialization)
const fileDrop = setupFileDropProvider()
const filePicker = setupFilePickerProvider()
setupImageViewerEditorProvider()
setupInstanceImportProvider(notificationManager)
return {
@@ -0,0 +1,16 @@
import {
type ImageViewerEditorData,
type ImageViewerEditorSource,
provideImageViewerEditor,
} from '@modrinth/ui'
import { readFile } from '@tauri-apps/plugin-fs'
export function setupImageViewerEditorProvider() {
provideImageViewerEditor({
async loadEditorData(source: ImageViewerEditorSource): Promise<ImageViewerEditorData> {
return {
source: new Blob([await readFile(source.path)], { type: 'image/png' }),
}
},
})
}
+12
View File
@@ -101,6 +101,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Starlight skin' }],
},
},
{
path: '/screenshots',
name: 'Screenshots',
component: () => import('@/pages/Screenshots.vue'),
meta: {
breadcrumb: [{ name: 'Screenshots' }],
},
},
{
path: '/multiplayer',
name: 'Multiplayer',
@@ -111,6 +119,10 @@ export default new createRouter({
pageTransitionGroup: 'multiplayer',
},
children: [
{
path: '',
redirect: { name: 'MultiplayerRooms' },
},
{
path: 'rooms',
name: 'MultiplayerRooms',
+40
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 = {
@@ -14,10 +20,12 @@ export const DEFAULT_FEATURE_FLAGS = {
skip_non_essential_warnings: false,
skip_unknown_pack_warning: false,
i18n_debug: false,
multiplayer_in_app: false,
show_instance_play_time: true,
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
@@ -129,6 +137,9 @@ export type ThemeStore = {
devMode: boolean
featureFlags: FeatureFlags
uiFont: string | null
monoFont: string | null
}
export const DEFAULT_THEME_STORE: ThemeStore = {
@@ -153,6 +164,9 @@ export const DEFAULT_THEME_STORE: ThemeStore = {
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
uiFont: null,
monoFont: null,
}
export const useTheming = defineStore('themeStore', {
@@ -248,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]
},
+4 -8
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 }
@@ -56,13 +58,7 @@ zip = { workspace = true }
[dependencies.tauri]
workspace = true
features = [
"devtools",
"macos-private-api",
"protocol-asset",
"tray-icon",
"unstable"
]
features = ["devtools", "macos-private-api", "protocol-asset", "tray-icon", "unstable"]
[build-dependencies]
cc = { workspace = true }
+67
View File
@@ -365,6 +365,7 @@ fn main() {
"install_existing_instance",
"hosted_default",
"hosted_set_session",
"hosted_has_game_account",
"hosted_create",
"hosted_binding",
"hosted_sync",
@@ -427,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",
@@ -508,6 +524,47 @@ fn main() {
"instance_edit_icon",
"instance_export_mrpack",
"instance_get_pack_export_candidates",
"instance_list_screenshots",
"instance_list_all_screenshots",
"instance_list_synced_screenshots",
"instance_save_edited_screenshot",
"instance_list_screenshot_groups",
"instance_create_screenshot_group",
"instance_rename_screenshot_group",
"instance_delete_screenshot_group",
"instance_set_screenshot_group_memberships",
"instance_import_screenshot_groups",
"instance_delete_screenshots",
"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,
@@ -524,6 +581,7 @@ fn main() {
"proxy_get",
"proxy_set",
"proxy_test",
"set_ui_scale",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
@@ -669,6 +727,7 @@ fn main() {
"file_save_as",
"file_read_dragged_file",
"screenshot_thumbnail",
"instance_icon_thumbnail",
"studio_read_text",
"studio_read_binary",
"studio_write_binary",
@@ -680,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
+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()
}
@@ -6,6 +6,9 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tauri::Manager;
use tauri_plugin_fs::FsExt;
use tauri_plugin_opener::OpenerExt;
use theseus::data::{
AppliedContentSetPatch, ContentItem, Dependency,
EditInstance as CoreEditInstance, InstanceInstallCandidate,
@@ -22,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,
@@ -103,6 +121,47 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_edit_icon,
instance_export_mrpack,
instance_get_pack_export_candidates,
instance_list_screenshots,
instance_list_all_screenshots,
instance_list_synced_screenshots,
instance_save_edited_screenshot,
instance_list_screenshot_groups,
instance_create_screenshot_group,
instance_rename_screenshot_group,
instance_delete_screenshot_group,
instance_set_screenshot_group_memberships,
instance_import_screenshot_groups,
instance_delete_screenshots,
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()
}
@@ -146,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)]
@@ -273,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,
@@ -330,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,
}
}
}
@@ -461,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,
@@ -1490,3 +1583,529 @@ pub async fn instance_edit_icon(
theseus::instance::edit_icon(instance_id, icon_path).await?;
Ok(())
}
#[derive(Serialize, Debug, Clone)]
pub struct InstanceScreenshot {
pub id: String,
pub instance_id: String,
pub instance_name: String,
pub file_name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub modified_at: i64,
pub group_id: Option<String>,
pub path: PathBuf,
pub url: String,
}
fn serialize_screenshot<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
s: theseus::instance::InstanceScreenshot,
) -> Result<InstanceScreenshot> {
if let Some(parent) = s.path.parent() {
crate::api::files::ensure_browsable(app, parent);
}
let mut url = super::utils::tauri_convert_file_src(&s.path)?;
app.asset_protocol_scope().allow_file(&s.path)?;
app.fs_scope().allow_file(&s.path)?;
url.query_pairs_mut()
.append_pair("revision", &s.modified_at.to_string());
Ok(InstanceScreenshot {
id: s.id,
instance_id: s.instance_id,
instance_name: s.instance_name,
file_name: s.file_name,
created_at: s.created_at,
modified_at: s.modified_at,
group_id: s.group_id,
path: s.path,
url: url.to_string(),
})
}
fn serialize_screenshots<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
items: Vec<theseus::instance::InstanceScreenshot>,
) -> Result<Vec<InstanceScreenshot>> {
items
.into_iter()
.map(|s| serialize_screenshot(app, s))
.collect()
}
#[tauri::command]
pub async fn instance_list_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
instance_id: &str,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_screenshots(instance_id).await?,
)
}
#[tauri::command]
pub async fn instance_list_all_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_all_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_list_synced_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_synced_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_save_edited_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
bytes: Vec<u8>,
mode: theseus::instance::ScreenshotEditSaveMode,
) -> Result<InstanceScreenshot> {
serialize_screenshot(
&app,
theseus::instance::save_edited_screenshot(key, bytes, mode).await?,
)
}
#[tauri::command]
pub async fn instance_list_screenshot_groups()
-> Result<Vec<theseus::instance::ScreenshotGroup>> {
Ok(theseus::instance::list_screenshot_groups().await?)
}
#[tauri::command]
pub async fn instance_create_screenshot_group(
name: String,
ids: Vec<String>,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::create_screenshot_group(name, ids).await?)
}
#[tauri::command]
pub async fn instance_rename_screenshot_group(
id: String,
new_name: String,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::rename_screenshot_group(id, new_name).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshot_group(id: String) -> Result<()> {
Ok(theseus::instance::delete_screenshot_group(id).await?)
}
#[tauri::command]
pub async fn instance_set_screenshot_group_memberships(
updates: Vec<theseus::instance::ScreenshotGroupMembershipUpdate>,
) -> Result<()> {
Ok(theseus::instance::set_screenshot_group_memberships(updates).await?)
}
#[tauri::command]
pub async fn instance_import_screenshot_groups(
groups: Vec<theseus::instance::ScreenshotGroupImport>,
) -> Result<()> {
Ok(theseus::instance::import_screenshot_groups(groups).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
) -> Result<()> {
Ok(theseus::instance::delete_screenshots(&keys).await?)
}
#[tauri::command]
pub async fn instance_export_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
path: PathBuf,
) -> Result<()> {
Ok(theseus::instance::export_screenshots(&keys, path).await?)
}
#[tauri::command]
pub async fn instance_move_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
target: &str,
) -> Result<Vec<theseus::instance::ScreenshotKey>> {
Ok(theseus::instance::move_screenshots(&keys, target).await?)
}
#[tauri::command]
pub async fn instance_open_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
) -> Result<()> {
let path = theseus::instance::get_screenshot_path(&key).await?;
app.opener()
.reveal_item_in_dir(path)
.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;
+17 -3
View File
@@ -1,5 +1,5 @@
use crate::api::Result;
use tauri::{Emitter, Runtime};
use tauri::{Emitter, Manager, Runtime};
use theseus::prelude::*;
use theseus::{ProxyConfig, ProxyTestResult};
@@ -12,7 +12,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
cancel_directory_change,
proxy_get,
proxy_set,
proxy_test
proxy_test,
set_ui_scale
])
.build()
}
@@ -32,11 +33,24 @@ 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(())
}
#[tauri::command]
pub async fn set_ui_scale(
app: tauri::AppHandle<impl Runtime>,
factor: f64,
) -> Result<()> {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_zoom(factor);
}
Ok(())
}
#[tauri::command]
pub async fn download_engine_set(
engine: settings::DownloadEngine,
+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())
Loaded 100 of 408 files, more files were not shown because too many files have changed in this diff. Show more