Compare commits

..
Author SHA1 Message Date
AxTps eabac1f11e docs: 补充 1.0.2-beta5 公告
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
2026-10-11 07:06:54 +08:00
Ax_Tps b776b99f26 Merge pull request #12 from ApTx2012/feat/ui
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-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
feat: 接入皮肤站公告 API
2026-10-11 06:13:00 +08:00
Ax_Tps daeedc4951 Merge pull request #10 from ApTx2012/feat/launch-customization
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-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
feat: 支持自定义 Minecraft 窗口标题
2026-10-11 06:12:25 +08:00
AxTps cadb7bbdcd feat: 接入皮肤站公告 API
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:34:00 +08:00
AxTps 7f3ab9d79b fix: 补 EditInstance DTO 缺失的 window_title 字段与透传
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:23:49 +08:00
AxTps 037bff2db3 Merge branch 'main' into feat/launch-customization
Sync source to CNB / Sync Git ref (push) Skipped
解决 en-US/zh-CN locale JSON 的末尾追加冲突:窗口标题键与公告中心键均保留。
2026-10-10 22:07:19 +08:00
AxTps 1406f7ded3 fix: 补 ProcessMetadata 测试构造点缺失的 window_title
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:01:42 +08:00
Ap_Tx c54acd2e36 fix: 补齐盔甲预览的中文翻译 (#11)
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-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
皮肤编辑器盔甲面板的 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 (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-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 (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-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 (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
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
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 (Windows x64) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
beta4 此前未打 tag,本次发布将其与皮肤编辑器盔甲预览
移植(PR #8)合并发布,故在 beta4 公告条目补充:
- 皮肤预览新增盔甲与盔甲纹饰渲染
- 盔甲预览选择的本地持久化
2026-10-10 19:25:39 +08:00
AxTps 403a883e0b fix: CI 钉住 cargo-shear 版本为 1.13.4
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-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
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 (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-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 (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-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-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 (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-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-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
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
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 / guardrails (push) Has been cancelled
Repository checks / tombi (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
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-10-04 15:06:20 +08:00
AxTps bb0b094002 fix: migration guard 豁免删除分支
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (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 / desktop (ubuntu-latest) (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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
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 / 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
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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
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 / 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
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-04 11:26:45 +08:00
AxTps a21fe3750b feat: 新增 Starlight 周年庆彩蛋(每年 10 月 4 日)
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (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
10 月 4 日(Starlight 首个发布日 2014-10-04)当天,启动器顶部显示周年横幅并飘落蛋糕/彩带,周年数按年份-2014 计算。新增 helpers/anniversary.ts 与 AnniversaryCelebration.vue;开发者页加入 force_anniversary_celebration 功能开关用于随时预览。
2026-10-04 11:23:27 +08:00
Ap_Tx 8a447f33f6 Merge pull request #7 from ApTx2012/feat/instance-sync
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl→Starlight 的跨实例同步移植,含后端命令层 + 前端 UI。
2026-10-04 10:59:58 +08:00
AxTps f73bc89f84 fix: 修复 CI 的 rustfmt 与 typos 报错
Sync source to CNB / Sync Git ref (push) Has been cancelled
为 instance 同步命令定义之间补空行并补齐文件末尾换行;将 game-settings-modal 的 languages.ts(Minecraft 语言名数据)加入 typos 排除,避免语言代码被误判为拼写错误。
2026-10-04 09:23:04 +08:00
AxTps 4f220201b3 feat: 跨实例同步移植第9轮(游戏设置编辑器)
Sync source to CNB / Sync Git ref (push) Has been cancelled
搬入 game-settings-modal(游戏选项精细编辑,10 文件):设置行编辑器、按键绑定、语言选择与文案表。新增 app-settings-modal provider 与 instance-settings-context 转发,并把 instance-settings 的 closeModal 改为支持关闭回调。SyncSettings 恢复游戏设置入口。补齐 game-options 相关 en-US / zh-CN 文案。
2026-10-04 09:18:33 +08:00
AxTps 83ee962c16 feat: 跨实例同步移植第8轮(前端 helpers + 同步设置 UI)
Sync source to CNB / Sync Git ref (push) Has been cancelled
新增同步 helpers(synced-options / synced-packs / game-options),在 instance helper 中补充同步类型与 API。新增实例设置「同步」标签页(SyncSettings)及 SyncSource / SyncedPacks / servers / command-history 四个弹窗,并把 CheckCircleButton、TeleportOverflowMenu 从 ui 包导出。同步相关的 en-US / zh-CN 文案已补齐(各 62 条)。game-settings-modal 留待下一轮。
2026-10-04 08:51:45 +08:00
AxTps f7380f1590 feat: 跨实例同步移植第7轮(命令注册 + ACL)
Sync source to CNB / Sync Git ref (push) Has been cancelled
在 apps/app 的 instance 插件注册 27 个同步命令(synced_options / synced_servers / synced_packs / game_options),并在 build.rs 补齐对应 ACL 权限。同时在 app-lib 的 api/instance 模块导出 InstanceSyncedOptions 与 SyncedOption,修复命令层 theseus::instance::InstanceSyncedOptions 的解析路径。
2026-10-04 08:37:39 +08:00
AxTps 121da431ed feat: 跨实例同步移植 local_settings_editor(2c 收尾)
Sync source to CNB / Sync Git ref (push) Has been cancelled
- local_settings_editor.rs:单实例(未同步)游戏设置的读取/预览/保存

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- synced_servers:6 文件全部搬入

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

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

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

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

- instance_rows 新增 get_instance_synced_options

- 前端 GameInstance 增加 synced_options 类型
2026-10-03 21:08:05 +08:00
AxTps 91e63fdb2c fix: 登录皮肤站后自动认证默认玩家,修复创建实例误报需要登录
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (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
- 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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
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
Sync LobeHub models / sync (push) Has been cancelled
2026-10-03 10:08:43 +08:00
AxTps a99a0a6e30 feat(backup): 实例备份第5轮——新增备份设置/排除选择器/仓库设置组件,接入实例设置与存储设置页,补齐中英繁文案
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 09:43:57 +08:00
AxTps 773cf387a3 feat(backup): 实例备份第4轮——新增前端 helper(instance-backup.ts)及单元测试 2026-10-03 09:37:41 +08:00
AxTps 7414477bbd feat(backup): 实例备份第3轮——注册 15 个 Tauri 备份命令并补齐 ACL 权限 2026-10-03 09:32:46 +08:00
AxTps e8f10ab1ef feat(backup): 实例备份第2轮——移植 backup.rs 核心实现并接入 instance 模块导出 2026-10-03 09:19:28 +08:00
AxTps e91ba2ae74 feat(backup): 实例备份第1轮——独立备份库迁移、settings 仓库路径字段、备份进度事件 2026-10-03 09:12:35 +08:00
AxTps 95a3e00b03 feat(fonts): 外观设置新增界面/等宽字体选择器,含中英繁中文案
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 23:04:12 +08:00
AxTps f490600ee2 feat(fonts): 前端字体工具函数、主题字体状态与设置类型 2026-10-02 21:57:24 +08:00
AxTps b57219f348 feat(fonts): 后端字体命令 fonts_get_system_fonts 及 ui_font/mono_font 设置字段 2026-10-02 21:33:13 +08:00
AxTps ead942abb2 feat: 移植忽略 SSL 证书错误选项(设置开关 + 重建 HTTP 客户端)
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (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
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 / 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
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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
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 20:38:31 +08:00
AxTps 8ec5cf104c chore: 补充实例页导览四步的 en-US 文案
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (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 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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
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: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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
Sync LobeHub models / sync (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
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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
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 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
Prepare pnpm cache / prepare (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
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
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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (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
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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (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
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 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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
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 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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (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
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 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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
Sync LobeHub models / sync (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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (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
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
- 移除意外提交的 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
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
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
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (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 / 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
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
AxTps 15b6575824 chore: 发布 1.0.1-beta3 公告
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
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 / 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
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-beta3 公告(界面缩放、首次启动公告、
  浏览页与链接修复);
- 网站 catalog.json 重新生成。
2026-09-30 23:13:34 +08:00
AxTps 5bd785d780 chore(i18n): 补充界面缩放设置的中英文文案并清理多余键
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
- en-US / zh-CN 新增 app.appearance-settings.ui-scale.title 与
  .description;
- 移除未使用的 ui-scale.custom / ui-scale.percent 文案键。
2026-09-30 22:55:34 +08:00
AxTps 94cbcef324 feat: 新增启动器界面缩放设置
在高缩放/小屏下,内嵌网页(如皮肤站)顶栏会被裁掉。新增“界面
与外观”中的“启动器缩放”设置,可整体缩放启动器界面。

- 新增 helpers/ui-scale.ts:用 localStorage 保存缩放值(纯本地
  显示偏好,无需后端),并通过 CSS zoom 应用缩放(含内嵌网页)。
- App.vue 启动时调用 installUiScale() 应用已保存的缩放。
- AppearanceSettings.vue 新增缩放设置项:100/110/125/150 预设
  加 50–200 滑条自定义。
2026-09-30 22:40:24 +08:00
AxTps d8a6d44890 fix: 关于页许可证与更新日志链接指向 GitHub 仓库
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
- config.ts:repositoryUrl 修正为启动器仓库
  (github.com/ApTx2012/Starlight_Lancher),此前误指向服务端仓库;
  新增 releasesUrl。
- 关于页“项目许可证”“第三方许可证”随之指向正确的 GitHub 路径。
- 更新页“查看完整更新日志”:当前版本回退到 releasesUrl;历史版本
  回退到对应 tag 的 release 页面。
2026-09-30 21:53:16 +08:00
AxTps 9b75ca4f3f fix: 未配置 CurseForge API Key 时浏览页仍显示导航栏
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
此前在世界地图浏览(projectType=world)且未配置 CurseForge API Key
时,整个 BrowsePageLayout 被 v-if 隐藏,导致选择栏(整合包/模组/
资源包/…/服务器/收藏夹)与搜索栏全部空白。

现在始终渲染 BrowsePageLayout,仅在其结果区上方(#above-results)
于该场景下显示“地图不可用 / 请配置 CurseForge API Key”的提示,导航
与搜索栏保持可见。
2026-09-30 21:31:07 +08:00
AxTps 913e0071dd 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
首次启动公告未弹出,原因有二:
- StartupAnnouncementModal 未 defineExpose({ show }),父组件调用
  show() 静默失败;
- 展示逻辑在调用 show() 之前就写入了 localStorage 的"已读"标记,
  一旦 show 失败便永久不再弹出。

修复:
- 组件补上 defineExpose({ show });
- 展示逻辑改为"确认能拿到组件实例并 show() 成功后再写标记",
  组件尚未挂载时于 nextTick 重试。

同时把首页侧栏的"每日小挑战"替换为可点击的"公告"入口
(HomeAnnouncementEntry),点击通过 provide/inject 的
showStartupAnnouncement 打开公告弹窗。
2026-09-30 21:11:40 +08:00
Disy bb6b8cb8d2 fix: 未登录时阻止下载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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-09-29 17:36:10 +08:00
Disy efbf998ebb feat: GitHub 发布生成皮肤站完整更新包
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / tombi (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 / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-09-28 15:27:14 +08:00
Disy ce0f41c973 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
Sync LobeHub models / sync (push) Has been cancelled
2026-09-27 14:18:25 +08:00
AxTps 2efaa4fb35 chore: 发布 1.0.1-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
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 / 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
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
Sync LobeHub models / sync (push) Has been cancelled
- catalog.ts 增量新增 1.0.1-beta1 公告(首次启动欢迎弹窗);
- 网站 catalog.json 重新生成。
2026-09-27 08:39:49 +08:00
AxTps 2d52d16933 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
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-09-27 08:24:13 +08:00
Axolotl Release Bot bbb9e5c054 chore(website): update download metadata to v1.0.0 [skip ci] 2026-09-26 07:26:43 +00:00
AxTps 15c375bca5 chore: 发布 1.0.0 正式版公告
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
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 / 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
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.0 公告(启用内置自动更新、修复启动崩溃);
- 网站 catalog.json 重新生成。
2026-09-26 14:25:28 +08:00
Disy d2a319a4d5 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-09-26 13:06:51 +08:00
Disy 2359b80250 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
Sync LobeHub models / sync (push) Has been cancelled
2026-09-26 11:00:01 +08:00
AxTps c273734aec fix: 临时回退内置自动更新以修复启动崩溃
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
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 / 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
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
1.0.0-beta14 启用内置更新后,Windows 上一打开即崩溃(c0000409 栈
溢出)。暂时将 are_updates_enabled 改回 false,待定位并修复崩溃后
再重新启用。

- main.rs: are_updates_enabled 恢复返回 false;
- catalog.ts: 增量新增 1.0.0-beta15 公告;
- 网站 catalog.json 重新生成。
2026-09-26 09:56:52 +08:00
AxTps f89cbff37e docs: README 链接改指向 GitHub
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
Repository checks / typos (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
将仓库、Releases、Stars 及最新版下载等链接从自建 git.starlight.cool
改为 GitHub (github.com/ApTx2012/Starlight_Lancher)。
2026-09-26 08:19:01 +08:00
AxTps f92aee9b4c feat: 启用内置自动更新
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
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / 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
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 / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
- are_updates_enabled 由硬编码 false 改为 true,正式启用内置更新器
  (更新端点与签名公钥由 tauri-release.conf.json 配置)。
- catalog.ts 新增 1.0.0-beta14 公告(增量追加,保留历史版本)。
- 网站 catalog.json 由 catalog.ts 重新生成。
2026-09-26 07:52:06 +08:00
AxTps 2192a19d50 chore: 移除 1.0.0-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
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
清理上游遗留的旧版本公告(1.9.6-beta.3 ~ 1.4.0),只保留本项目
自己的版本记录(1.0.0-beta1 及之后的 1.0.0-beta13)。

- catalog.ts 删除 49 条旧公告;
- 网站 catalog.json 由 create-website-release-catalog.mjs 从
  catalog.ts 重新生成,现仅含 1.0.0-beta13 与 1.0.0-beta1。
2026-09-26 07:35:49 +08:00
AxTps 9411d0945a fix(release): 修正 updater 清单的下载 URL 与发布顺序
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
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 / 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
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
发布时 verify update manifest 失败:draft release 的资产 URL 为
`.../releases/download/untagged-<hash>/...`,而校验要求 URL 含 tag 名。

- create-update-manifest.mjs 不再依赖 draft release 的
  browser_download_url,改为按 tag 与资产名自行拼接稳定的
  `.../releases/download/<tag>/<name>` 地址。
- build-on-tag.yml 将 Publish release 提前到生成/校验清单之前,
  使清单在 release 已发布后生成;并移除多余的
  “Refresh published GitHub release metadata” 步骤。
- catalog.ts 版本公告更新为 1.0.0-beta13。
2026-09-26 06:04:02 +08:00
477 changed files with 49301 additions and 33370 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"
+37 -25
View File
@@ -303,6 +303,20 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release download "${GITHUB_REF_NAME}" --pattern '*.sig' --dir release-check/signatures
# Publish the draft release before generating the updater manifest. While
# the release is a draft GitHub reports asset URLs as
# `.../releases/download/untagged-<hash>/...`; only after publishing does
# the manifest point at the stable `.../releases/download/<tag>/...` path.
- name: Publish release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
gh release edit "${GITHUB_REF_NAME}" --draft=false --prerelease
else
gh release edit "${GITHUB_REF_NAME}" --draft=false --latest
fi
- name: Download release metadata
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -329,31 +343,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${GITHUB_REF_NAME}" release-check/latest.json --clobber
- name: Publish release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
gh release edit "${GITHUB_REF_NAME}" --draft=false --prerelease
else
gh release edit "${GITHUB_REF_NAME}" --draft=false --latest
fi
- name: Refresh published GitHub release metadata
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
release_id="$(
gh api "repos/${GITHUB_REPOSITORY}/releases" \
--paginate \
--jq ".[] | select(.tag_name == \"${GITHUB_REF_NAME}\") | .id"
)"
if [[ -z "$release_id" ]]; then
echo "Published release ${GITHUB_REF_NAME} was not found"
exit 1
fi
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > release-check/release.json
- name: Build Update Server artifact catalog
run: |
node scripts/axolotl/create-update-server-catalog.mjs \
@@ -362,6 +351,29 @@ jobs:
release-check/update-server-catalog.json
node scripts/axolotl/create-update-server-catalog.test.mjs
- name: Build complete skin-site update package
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version="${GITHUB_REF_NAME#v}"
bundle_root="release-check/skin-site-update"
bundle_name="Starlight_Launcher_${version}_skin-site-update.zip"
mkdir -p "${bundle_root}/files"
gh release download "${GITHUB_REF_NAME}" --dir "${bundle_root}/files"
find "${bundle_root}/files" -maxdepth 1 -type f \
\( -name 'latest.json' -o -name '*_skin-site-update.zip' \) -delete
cp release-check/latest.json "${bundle_root}/latest.json"
cp release-check/update-server-catalog.json "${bundle_root}/update-server-catalog.json"
cp release-check/release.json "${bundle_root}/release.json"
(
cd "${bundle_root}"
zip -r -9 "../${bundle_name}" latest.json update-server-catalog.json release.json files
)
gh release upload "${GITHUB_REF_NAME}" "release-check/${bundle_name}" --clobber
- name: Notify Update Server to publish release assets
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
env:
+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-beta12"
version = "1.0.2-beta4"
dependencies = [
"ariadne",
"async-minecraft-ping",
@@ -6899,7 +6945,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "1.0.0-beta12"
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",
+6 -6
View File
@@ -5,13 +5,13 @@
<p><strong>次世代 Minecraft 桌面客户端,全能、美观、全平台覆盖。</strong></p>
<p>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/actions">
<img src="https://img.shields.io/badge/Repo-git.starlight.cool-blue?style=for-the-badge&logo=git" alt="Repository" />
<a href="https://github.com/ApTx2012/Starlight_Lancher/actions">
<img src="https://img.shields.io/badge/Repo-GitHub-black?style=for-the-badge&logo=github" alt="Repository" />
</a>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/releases">
<a href="https://github.com/ApTx2012/Starlight_Lancher/releases">
<img src="https://img.shields.io/badge/Releases-Download-green?style=for-the-badge&logo=github" alt="Releases" />
</a>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/stargazers">
<a href="https://github.com/ApTx2012/Starlight_Lancher/stargazers">
<img src="https://img.shields.io/badge/Stars-★-ffb800?style=for-the-badge&logo=github" alt="Stars" />
</a>
<a href="COPYING.md">
@@ -21,7 +21,7 @@
<p>
<a href="https://skin.starlight.cool/">官方网站</a> |
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest">下载最新版</a> |
<a href="https://github.com/ApTx2012/Starlight_Lancher/releases/latest">下载最新版</a> |
<a href="CONTRIBUTING.md">参与贡献</a>
| <a href="CODE_OF_CONDUCT.md">行为准则</a>
</p>
@@ -57,7 +57,7 @@ _(注:本项目是调用 Modrinth 公开 API 的独立客户端,与 Rinth, I
## 下载与安装
请前往 [Releases](https://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest) 下载适合你操作系统的最新安装包。
请前往 [Releases](https://github.com/ApTx2012/Starlight_Lancher/releases/latest) 下载适合你操作系统的最新安装包。
已安装的用户每次均可通过内置的 Tauri 签名校验机制,自动在后台完成更新,无需手动下载安装更新。
| 系统平台 | 推荐下载文件 |
+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-beta12",
"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:*",
+102 -10
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 {
@@ -66,8 +68,11 @@ import { hideAllPoppers } from 'floating-vue'
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
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'
@@ -79,7 +84,6 @@ import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/
import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue'
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue'
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
@@ -90,9 +94,10 @@ import NavButton from '@/components/ui/NavButton.vue'
import NavRail from '@/components/ui/NavRail.vue'
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import InstancePlayerModal from '@/components/instance/InstancePlayerModal.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'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useDropImport } from '@/composables/useDropImport'
@@ -138,9 +143,9 @@ import {
isDev,
isElevated,
isNetworkMetered,
restartApp,
setRestartAfterPendingUpdate,
} from '@/helpers/utils.js'
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 {
@@ -312,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('')
@@ -490,9 +511,16 @@ onMounted(async () => {
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, handleDirectLinkSyncReport)
try {
await stateInitialization
} catch {
return
}
checkUpdates()
void warnIfRunningElevated()
startDirectLinkSync()
maybeShowStartupAnnouncement()
})
let directLinkSync: (() => Promise<void>) | undefined
@@ -741,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',
@@ -1066,6 +1098,7 @@ async function setupApp() {
close_behavior,
developer_mode,
feature_flags,
ui_scale,
} = initialSettings
// Initialize locale from saved settings
@@ -1096,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)
@@ -1228,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
@@ -1264,6 +1298,9 @@ provide(
)
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
provide('showLauncherPopup', (_request: unknown) => {})
provide('showStartupAnnouncement', () => startupAnnouncementModal.value?.show())
installUiScale()
const stateFailed = ref(false)
stateInitialization
@@ -1533,6 +1570,33 @@ const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
const startupAnnouncementModal = ref<InstanceType<typeof StartupAnnouncementModal> | null>(null)
const STARTUP_ANNOUNCEMENT_KEY = 'starlight-startup-announcement-seen-v1'
function maybeShowStartupAnnouncement() {
let alreadySeen = false
try {
alreadySeen = localStorage.getItem(STARTUP_ANNOUNCEMENT_KEY) !== null
} catch (error) {
console.warn('Failed to read startup announcement flag', error)
}
if (alreadySeen) return
const modal = startupAnnouncementModal.value
if (!modal) {
// The modal component has not mounted yet; try again on the next tick.
void nextTick(maybeShowStartupAnnouncement)
return
}
modal.show()
try {
localStorage.setItem(STARTUP_ANNOUNCEMENT_KEY, '1')
} catch (error) {
console.warn('Failed to write startup announcement flag', error)
}
}
// ── Drop import system ──────────────────────────────────────────────────
const dropImport = useDropImport({
notificationManager,
@@ -1701,6 +1765,7 @@ onMounted(() => {
setServerUpdateToPlayModal(updateToPlayModal.value)
void (async () => {
try {
await stateInitialization
const ready = await invoke<{
pending_crashes: { instance_id: string; uuid: string }[]
pending_commands: Parameters<typeof handleCommand>[0][]
@@ -2158,9 +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"
@@ -2256,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"
@@ -2423,9 +2508,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
{{ formatMessage(messages.authUnreachableBody) }}
</Admonition>
<div class="page-transition-grid grid min-h-full">
<RouterView v-slot="{ Component, route }">
<RouterView v-slot="{ Component, route: currentRoute }">
<Transition name="page-slide" :css="themeStore.getFeatureFlag('page_transitions')">
<div v-if="Component" :key="getPageTransitionKey(route)" class="page-transition-layer">
<div
v-if="Component"
:key="getPageTransitionKey(currentRoute)"
class="page-transition-layer"
>
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<component :is="Component"></component>
</Suspense>
@@ -2465,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>
@@ -2489,7 +2581,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:on-error-action="exportNotificationErrorLogs"
:error-action-label="formatMessage(messages.exportErrorLogs)"
/>
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
<MinecraftCrashModal v-if="stateInitialized" ref="minecraftCrashModal" @error="handleError" />
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
<NewModal
ref="closeChoiceModal"
@@ -2577,7 +2669,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@install="handleContentInstallModpackInstall"
@cancel="handleContentInstallModpackInstallCancel"
/>
<TaggedModDownloadsModal />
<TaggedModDownloadsModal v-if="stateInitialized" />
<CurseForgeManualDownloadsModal
ref="contentInstallCurseForgeManualDownloadsModal"
@view-instance="handleContentInstallModpackDuplicateGoToInstance"
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { NewspaperIcon, RightArrowIcon } from '@modrinth/assets'
import { inject } from 'vue'
const showAnnouncement = inject<(() => void) | undefined>('showStartupAnnouncement')
function open() {
showAnnouncement?.()
}
</script>
<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
type="button"
class="group flex w-full cursor-pointer flex-col gap-2 border-0 bg-transparent p-0 text-left"
@click="open"
>
<div class="flex items-center gap-2">
<NewspaperIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 truncate text-lg">公告</h2>
<RightArrowIcon
class="ml-auto size-4 shrink-0 text-secondary transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</div>
<p class="m-0 text-sm leading-relaxed text-secondary">点击查看最新公告</p>
</button>
</section>
</template>
@@ -28,8 +28,8 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
@@ -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">
@@ -36,6 +36,7 @@ const props = withDefaults(
limit?: HomeRecentLimit
}>(),
{
dashboardSize: null,
limit: HOME_RECENT_DEFAULT_LIMIT,
},
)
@@ -146,11 +147,17 @@ async function stopInstance(instance: GameInstance) {
</script>
<template>
<section class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
<section
class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3"
:data-size="dashboardSize"
>
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
<h2>{{ formatMessage(messages.recentTitle) }}</h2>
</div>
<div v-if="recentItems.length > 0" class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1">
<div
v-if="recentItems.length > 0"
class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1"
>
<template
v-for="item in recentItems"
:key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`"
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ProgressBar, defineMessages, useVIntl } from '@modrinth/ui'
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { injectDownloadManager } from '@/providers/download-manager'
const props = defineProps<{ instanceId?: string; active?: boolean }>()
@@ -14,13 +14,14 @@ import {
import { users } from '@/helpers/auth'
import { getInstanceMode } from '@/helpers/hosted-packs'
import {
type InstancePlayer,
type PlayerChoice,
PlayerSelectionNavigatedAwayError,
registerInstancePlayerPicker,
saveInstancePlayer,
waitForSkinSiteSession,
type InstancePlayer,
type PlayerChoice,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
chooseInstancePlayer,
getInstancePlayer,
onInstancePlayerChanged,
type InstancePlayer,
onInstancePlayerChanged,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { NewModal, ProgressBar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, onUnmounted, nextTick, ref, shallowRef } from 'vue'
import { ButtonStyled, defineMessages, NewModal, ProgressBar, useVIntl } from '@modrinth/ui'
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { loading_listener } from '@/helpers/events'
import { progress_bars_list } from '@/helpers/state'
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
import { progress_bars_list } from '@/helpers/state'
import {
createTaggedModProgress,
type TaggedProgressEvent,
@@ -12,10 +12,14 @@
>
{{ formatMessage(messages.score, { score }) }}
</span>
<span class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary">
<span
class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary"
>
{{ formatMessage(messages.highestLevel, { level: highestLevel + 1 }) }}
</span>
<span class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary">
<span
class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary"
>
{{ formatMessage(messages.best, { score: bestScore }) }}
</span>
<NewButton type="base" size="sm" class="shrink-0" @click="settleGame">
@@ -80,7 +84,10 @@
</div>
</div>
</Transition>
<div v-if="gameOver && !endedManually" class="tide-drain pointer-events-none absolute inset-0 z-20" />
<div
v-if="gameOver && !endedManually"
class="tide-drain pointer-events-none absolute inset-0 z-20"
/>
<Transition name="panel">
<div
v-if="gameOver"
@@ -115,16 +122,26 @@
</div>
<div class="grid w-full min-w-0 grid-cols-3 gap-1.5">
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
<span class="text-[9px] text-secondary">{{ formatMessage(messages.levelLabel) }}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{ highestLevel + 1 }}</span>
<span class="text-[9px] text-secondary">{{
formatMessage(messages.levelLabel)
}}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{
highestLevel + 1
}}</span>
</div>
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
<span class="text-[9px] text-secondary">{{ formatMessage(messages.bestLabel) }}</span>
<span class="text-[9px] text-secondary">{{
formatMessage(messages.bestLabel)
}}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{ bestScore }}</span>
</div>
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
<span class="text-[9px] text-secondary">{{ formatMessage(messages.overtimesLabel) }}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{ overtimeCount }}</span>
<span class="text-[9px] text-secondary">{{
formatMessage(messages.overtimesLabel)
}}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{
overtimeCount
}}</span>
</div>
</div>
<div class="flex flex-wrap justify-center gap-2">
@@ -150,8 +167,8 @@ import blueStar from '@/assets/stars/blue.png'
import greenStar from '@/assets/stars/green.png'
import pinkStar from '@/assets/stars/pink.png'
import redStar from '@/assets/stars/red.png'
import yellowStar from '@/assets/stars/yellow.png'
import superStar from '@/assets/stars/super.png'
import yellowStar from '@/assets/stars/yellow.png'
interface Piece {
id: number
@@ -396,7 +413,14 @@ function mergePieces(a: Piece, b: Piece) {
score.value += points[level]
highestLevel.value = Math.max(highestLevel.value, level)
const isRainbow = level === colors.length - 1
spawnBurst(x, y, isRainbow ? rainbowColors : [colors[level]], 10 + level * 3, 130 + level * 20, 3.4)
spawnBurst(
x,
y,
isRainbow ? rainbowColors : [colors[level]],
10 + level * 3,
130 + level * 20,
3.4,
)
spawnShockwave(x, y, 46 + level * 26, 'rgba(255, 255, 255, 0.9)', Math.min(3, 1.4 + level * 0.3))
shakePower = Math.max(shakePower, Math.min(2 + level * 1.2, 7))
spawnFloater(x, y, `+${points[level]}`, 15 + level * 3, colors[level])
@@ -423,7 +447,14 @@ function easeOutBack(t: number) {
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
}
function spawnBurst(x: number, y: number, palette: string[], count: number, speed: number, size: number) {
function spawnBurst(
x: number,
y: number,
palette: string[],
count: number,
speed: number,
size: number,
) {
for (let i = 0; i < count; i++) {
if (particles.length >= 260) return
const angle = Math.random() * Math.PI * 2
@@ -508,7 +539,8 @@ function update(delta: number) {
if (overtimeStartAt >= 0) {
const overtimeElapsed = elapsed - overtimeStartAt
if (overtimeElapsed < 0.8) {
surfaceRatio = overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8)
surfaceRatio =
overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8)
} else {
overtimeStartAt = -1
}
@@ -670,17 +702,7 @@ function prepareBallSprite(src: string, level: number) {
sprite.height = Math.max(1, Math.round(bounds.h * scale))
const ctx = sprite.getContext('2d')
if (!ctx) return
ctx.drawImage(
image,
bounds.x,
bounds.y,
bounds.w,
bounds.h,
0,
0,
sprite.width,
sprite.height,
)
ctx.drawImage(image, bounds.x, bounds.y, bounds.w, bounds.h, 0, 0, sprite.width, sprite.height)
ballSprites[level] = sprite
}
image.src = src
@@ -692,9 +714,7 @@ function drawMergePiece(piece: Piece) {
const spawnProgress = Math.min(1, (elapsed - piece.spawnAt) / 0.22)
const spawnScale = spawnProgress >= 1 ? 1 : easeOutBack(spawnProgress)
const radius =
piece.radius *
spawnScale *
(isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
piece.radius * spawnScale * (isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
context.save()
context.translate(piece.x, piece.y)
const hue = (elapsed * 55) % 360
@@ -792,9 +812,7 @@ function draw() {
context.beginPath()
for (let x = 0; x <= width; x += 8) {
const waveY =
surfaceY +
Math.sin(x * 0.045 + elapsed * 1.8) * 2.5 +
Math.sin(x * 0.013 + elapsed * 0.9) * 2
surfaceY + Math.sin(x * 0.045 + elapsed * 1.8) * 2.5 + Math.sin(x * 0.013 + elapsed * 0.9) * 2
if (x === 0) context.moveTo(x, waveY)
else context.lineTo(x, waveY)
}
@@ -807,7 +825,12 @@ function draw() {
const progress = Math.min(1, (elapsed - waveStart) / 0.8)
const waveY = height * (1 - progress)
const bandHeight = Math.max(22, height * 0.1)
const gradient = context.createLinearGradient(0, waveY - bandHeight, 0, waveY + bandHeight * 0.5)
const gradient = context.createLinearGradient(
0,
waveY - bandHeight,
0,
waveY + bandHeight * 0.5,
)
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)')
gradient.addColorStop(0.55, 'rgba(96, 178, 255, 0.5)')
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
@@ -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',
@@ -11,8 +11,8 @@ import {
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { join } from '@tauri-apps/api/path'
import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
@@ -20,8 +20,8 @@ import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
@@ -98,7 +98,7 @@ const checkProcess = async () => {
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
}
const play = async (e, context) => {
const play = async (e) => {
e?.stopPropagation()
if (loading.value) return
await run(props.instance.id).catch(async (err) => {
@@ -111,7 +111,7 @@ const play = async (e, context) => {
await checkProcess()
}
const stop = async (e, context) => {
const stop = async (e) => {
e?.stopPropagation()
await kill(props.instance.id).catch(handleError)
await checkProcess()
@@ -205,7 +205,7 @@ onUnmounted(() => unlisten())
: formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:disabled="offline && !installed"
@click="(e) => play(e, 'InstanceCard')"
@click="play"
@mousehover="checkProcess"
>
<!-- Translate for optical centering -->
@@ -295,7 +295,7 @@ onUnmounted(() => unlisten())
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
!disabled,
}"
@click="(e) => play(e, 'InstanceCard')"
@click="play"
@mousehover="checkProcess"
>
<PlayIcon class="translate-x-[1px]" />
@@ -522,7 +522,7 @@ async function copyShareUrl(): Promise<void> {
}
}
function _openAIAnalysis(): void {
function openAIAnalysis(): void {
if (!lastAnalysis?.combined_log) {
notifyNoLogContent()
return
@@ -603,7 +603,7 @@ onUnmounted(() => {
unlistenProcess?.()
})
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis: _openAIAnalysis })
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis })
</script>
<template>
@@ -0,0 +1,420 @@
<script setup lang="ts">
import {
Button,
commonMessages,
defineMessages,
NewModal,
useModalStack,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import {
announcementKey,
isAnnouncementActive,
mapBroadcastAnnouncements,
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
parseAnnouncements,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
type RemoteAnnouncement,
safeAnnouncementUrl,
} from '@/helpers/remote-announcements'
import {
requestSkinSiteDownloadToken,
skinSiteStatus,
waitForSkinSiteSession,
} from '@/composables/skin-site-session.ts'
const props = defineProps<{ ready: boolean; previewOnly?: boolean }>()
const { formatMessage } = useVIntl()
const { hasModal } = useModalStack()
const messages = defineMessages({
view: { id: 'app.remote-announcements.view', defaultMessage: 'View announcement' },
unread: { id: 'app.remote-announcements.unread', defaultMessage: 'Unread' },
readAll: {
id: 'app.remote-announcements.read-all',
defaultMessage: 'Mark all announcements as read',
},
centerTitle: { id: 'app.remote-announcements.center-title', defaultMessage: 'Announcements' },
empty: { id: 'app.remote-announcements.empty', defaultMessage: 'No announcements' },
previewTitle: {
id: 'app.remote-announcements.preview-title',
defaultMessage: 'Announcement style preview',
},
previewSummary: {
id: 'app.remote-announcements.preview-summary',
defaultMessage:
'This is a local preview. Select View announcement to preview the full Markdown content.',
},
previewContent: {
id: 'app.remote-announcements.preview-content',
defaultMessage:
'## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://skin.starlight.cool/)',
},
previewAction: { id: 'app.remote-announcements.preview-action', defaultMessage: 'Visit website' },
})
const modal = ref<InstanceType<typeof NewModal>>()
const selected = ref<RemoteAnnouncement | null>(null)
const active = ref(false)
const centerOpen = ref(false)
const startupNotice = ref<RemoteAnnouncement | null>(null)
const html = computed(() => renderString(selected.value?.content ?? ''))
const stateKey = 'starlight-remote-announcements-v2'
const read = reactive(new Set<string>())
const queuedThisSession = new Set<string>()
const startupNotified = new Set<string>()
let items: RemoteAnnouncement[] = []
let pending: RemoteAnnouncement[] = []
let cacheKey = 'starlight-remote-announcements-cache-v1'
let cacheLoaded = false
let disposed = false
let interval: ReturnType<typeof setInterval> | undefined
let advanceTimer: ReturnType<typeof setTimeout> | undefined
function persist() {
if (props.previewOnly) return
try {
localStorage.setItem(
stateKey,
JSON.stringify({
read: [...read].slice(-1000),
}),
)
} catch {
// localStorage may be unavailable (private mode / quota)
}
}
function emitState() {
window.dispatchEvent(
new CustomEvent(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, {
detail: {
items,
unreadKeys: items.filter((item) => !read.has(announcementKey(item))).map(announcementKey),
},
}),
)
}
function openCenter(item?: RemoteAnnouncement) {
if (!props.ready || disposed) return
centerOpen.value = true
if (item) {
startupNotice.value = null
selectAnnouncement(item)
} else if (!selected.value && items.length) {
selectAnnouncement(items[0])
}
void nextTick(() => modal.value?.show())
}
function handleOpenCenter() {
if (props.previewOnly) return
openCenter()
}
async function show(item: RemoteAnnouncement) {
if (
disposed ||
!props.ready ||
!isAnnouncementActive(item) ||
(hasModal.value && !active.value && !centerOpen.value)
)
return
centerOpen.value = true
active.value = true
selectAnnouncement(item)
await nextTick()
if (!disposed) modal.value?.show()
}
function selectAnnouncement(item: RemoteAnnouncement) {
selected.value = item
const key = announcementKey(item)
read.add(key)
pending = pending.filter((entry) => announcementKey(entry) !== key)
persist()
emitState()
}
function markAllRead() {
for (const item of items) {
read.add(announcementKey(item))
}
pending = []
persist()
emitState()
}
function advance() {
if (disposed || !props.ready || hasModal.value || active.value) return
const next = pending.shift()
if (next) {
void show(next)
return
}
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'notification' &&
!startupNotified.has(key) &&
!read.has(key) &&
isAnnouncementActive(item)
) {
startupNotice.value = item
startupNotified.add(key)
setTimeout(() => {
if (startupNotice.value === item) {
startupNotice.value = null
advance()
}
}, 8000)
break
}
}
}
function closed() {
active.value = false
centerOpen.value = false
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
}
function sync(next: RemoteAnnouncement[], fresh: boolean) {
items = next.filter((item) => isAnnouncementActive(item))
const keys = new Set(items.map(announcementKey))
pending = pending.filter((item) => keys.has(announcementKey(item)))
if (selected.value && !keys.has(announcementKey(selected.value))) selected.value = null
emitState()
if (fresh) {
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'modal' &&
!queuedThisSession.has(key) &&
(!read.has(key) || item.priority === 'critical')
) {
queuedThisSession.add(key)
pending.push(item)
}
}
advance()
}
}
function loadCache() {
if (cacheLoaded || !cacheKey) return
cacheLoaded = true
try {
const cached = JSON.parse(localStorage.getItem(cacheKey) ?? 'null')
if (cached && typeof cached.savedAt === 'number' && Date.now() - cached.savedAt < 86400000) {
const parsed = parseAnnouncements(cached.items)
if (parsed) sync(parsed, false)
}
} catch {
// Ignore malformed or expired cache payloads
}
}
function saveCache(next: RemoteAnnouncement[]) {
if (!cacheKey) return
try {
localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), items: next }))
} catch {
// localStorage may be unavailable (private mode / quota)
}
}
async function refresh() {
if (disposed) return
loadCache()
if (skinSiteStatus.value !== 'signed-in') return
try {
await waitForSkinSiteSession()
const token = await requestSkinSiteDownloadToken()
const response = await tauriFetch('https://skin.starlight.cool/starlight/skin/broadcast/all', {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) return
const data = await response.json()
const parsed = mapBroadcastAnnouncements((data as { payload?: unknown } | null)?.payload)
if (parsed && !disposed) {
sync(parsed, true)
saveCache(parsed)
}
} catch {
// Remote fetch failed; previously cached items remain displayed
}
}
async function openLink(value: unknown) {
const url = safeAnnouncementUrl(value)
if (!url) return
try {
await openUrl(url)
} catch {
// Opener may reject unknown schemes
}
}
function contentClick(event: MouseEvent) {
const link = event.target instanceof Element ? event.target.closest('a') : null
if (!link) return
event.preventDefault()
event.stopPropagation()
void openLink(link.getAttribute('href'))
}
function preview(type: RemoteAnnouncement['type'], withAction = false) {
if (!props.previewOnly || !props.ready || hasModal.value || disposed) return
const now = new Date().toISOString()
const item: RemoteAnnouncement = {
id: 'local-preview-' + type,
title: formatMessage(messages.previewTitle),
summary: formatMessage(messages.previewSummary),
content: formatMessage(messages.previewContent),
type,
priority: 'normal',
starts_at: now,
ends_at: null,
published_at: now,
action_label: withAction ? formatMessage(messages.previewAction) : null,
action_url: withAction ? 'https://skin.starlight.cool/' : null,
}
read.clear()
queuedThisSession.clear()
sync([item], true)
}
defineExpose({ preview })
onMounted(() => {
if (props.previewOnly) return
try {
const saved = JSON.parse(localStorage.getItem(stateKey) ?? 'null')
if (saved && Array.isArray(saved.read))
for (const key of saved.read) if (typeof key === 'string') read.add(key)
} catch {
// Ignore malformed local read-state
}
window.addEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
const start = () => {
if (disposed) return
void refresh()
interval = setInterval(() => {
sync(items, false)
advance()
}, 15000)
}
// Defer the startup request until after first paint.
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(start, { timeout: 2000 })
} else {
setTimeout(start, 500)
}
})
watch([() => props.ready, hasModal], () => {
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
})
onUnmounted(() => {
disposed = true
if (interval) clearInterval(interval)
if (advanceTimer) clearTimeout(advanceTimer)
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
})
</script>
<template>
<div
v-if="startupNotice"
class="fixed right-4 top-16 z-50 w-[22rem] max-w-[calc(100vw-2rem)] rounded-xl border border-surface-5 bg-surface-3 p-3 shadow-lg"
>
<button class="w-full text-left" @click="startupNotice && openCenter(startupNotice)">
<div class="mb-1 text-xs font-semibold uppercase text-brand">
{{ formatMessage(messages.unread) }}
</div>
<div class="font-semibold text-[var(--color-text-primary)]">{{ startupNotice.title }}</div>
<div
v-if="startupNotice.summary"
class="mt-1 line-clamp-2 text-sm text-[var(--color-text-tertiary)]"
>
{{ startupNotice.summary }}
</div>
</button>
</div>
<NewModal
ref="modal"
:header="formatMessage(messages.centerTitle)"
:on-hide="closed"
max-width="80vw"
width="80vw"
max-content-height="calc(80vh - 8rem)"
scrollable
>
<div class="flex h-[calc(80vh-8rem)] min-h-0 gap-3">
<aside class="flex min-h-0 w-[32%] min-w-[14rem] flex-col border-r border-surface-5 pr-3">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.centerTitle)
}}</span>
<Button
v-if="items.some((item) => !read.has(announcementKey(item)))"
@click="markAllRead"
>{{ formatMessage(messages.readAll) }}</Button
>
</div>
<div
v-if="!items.length"
class="py-8 text-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
<div v-else class="flex min-h-0 flex-col gap-1 overflow-auto">
<button
v-for="item in items"
:key="announcementKey(item)"
class="flex items-start gap-2 rounded-lg p-2 text-left hover:bg-surface-4"
:class="{
'bg-surface-4': selected && announcementKey(selected) === announcementKey(item),
}"
@click="selectAnnouncement(item)"
>
<span
class="mt-1.5 size-2 shrink-0 rounded-full"
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
item.title
}}</span>
<span class="block line-clamp-2 text-xs text-[var(--color-text-tertiary)]">{{
item.summary || item.content
}}</span>
</span>
</button>
</div>
</aside>
<section v-if="selected" class="min-h-0 min-w-0 flex-1 overflow-auto px-1">
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
{{ selected.title }}
</h2>
<div
class="markdown-body break-words"
@click="contentClick"
@auxclick="contentClick"
v-html="html"
/>
<div class="mt-4 flex justify-end">
<Button
v-if="selected.action_url && selected.action_label"
type="colored"
color="brand"
@click="openLink(selected.action_url)"
>{{ selected.action_label }}</Button
>
</div>
</section>
<div
v-else
class="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<Button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }} </Button>
</div>
</template>
</NewModal>
</template>
@@ -16,6 +16,7 @@ const messages = defineMessages({
<template>
<iframe
:src="skinSiteFrameUrl"
allow="clipboard-write; clipboard-read"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
/>
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { clearHostedSession } from '@/helpers/hosted-packs'
import {
receiveSkinSiteMessage,
@@ -10,6 +9,7 @@ import {
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { clearHostedSession } from '@/helpers/hosted-packs'
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ExternalIcon, UsersIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, NewModal } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { teamMembers } from '@/data/about'
const modal = ref<InstanceType<typeof NewModal>>()
const SERVER_IPS: { label: string; host: string }[] = [
{ label: '直连 IPv4', host: 'slv4.starlight.cool' },
{ label: '直连 IPv6', host: 'slv6.starlight.cool' },
{ label: '北方代理 IPv4', host: 'north.starlight.cool' },
{ label: '南方代理 IPv4', host: 'south.starlight.cool' },
{ label: '美国代理 IPv4', host: 'usa.starlight.cool' },
]
const RULES_URL = 'https://kdocs.cn/l/cmT6lVdryHJu'
const QQ_GROUP = '535392227'
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function openLink(url: string) {
void openUrl(url)
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" header="欢迎使用 Starlight Launcher" width="640px" max-width="640px">
<div class="flex min-w-0 flex-col gap-5">
<!-- 1. 开发组 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 flex items-center gap-2 text-sm font-semibold text-contrast">
<UsersIcon class="size-4 text-secondary" />
开发团队
</h3>
<p class="m-0 text-sm text-secondary">
本启动器由
<span class="font-semibold text-contrast">Ax_Tps</span>
和
<span class="font-semibold text-contrast">Disy920</span>
基于 Axolotl 开发。
</p>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="member in teamMembers"
:key="member.name"
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
@click.prevent="member.url && openLink(member.url)"
>
<Avatar :src="member.avatarUrl" :alt="member.name" size="2.5rem" circle no-shadow />
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{ member.name }}</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</div>
</section>
<!-- 2. SLS 专属 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">Starlight 专用启动器</h3>
<p class="m-0 text-sm text-secondary">
该启动器为 Starlight 专用。如果您还未加入 StarLight,请前往我们的 QQ 审核群
<button
type="button"
class="cursor-pointer border-0 bg-transparent p-0 font-semibold text-brand underline"
@click="openLink(`https://qm.qq.com/q/${QQ_GROUP}`)"
>
{{ QQ_GROUP }}
</button>
。
</p>
</section>
<!-- 3. 服规更新 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">StarLight 总则章程更新</h3>
<p class="m-0 text-sm text-secondary">
StarLight 总则章程(即“服规”)更新至第 15 版。该版服规进一步完善与严格了聚落建立、改组、招新与运营的全链条要求,请大家注意阅读并遵守!本版服规自
2026 年 9 月 27 日 0 点起开始试行。
</p>
<button
type="button"
class="flex w-fit cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-sm font-semibold text-brand underline"
@click="openLink(RULES_URL)"
>
查看服规链接
<ExternalIcon class="size-3.5" />
</button>
</section>
<!-- 4. 服务器连接 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">服务器连接</h3>
<p class="m-0 text-sm text-secondary">服务器目前下列几项 IP 可用:</p>
<ul class="m-0 flex flex-col gap-1 pl-5 text-sm text-secondary">
<li v-for="ip in SERVER_IPS" :key="ip.host">
<span class="text-contrast">{{ ip.label }}</span>
:<code class="text-brand">{{ ip.host }}</code>
</li>
</ul>
<p class="m-0 text-sm text-secondary">
请注意,我们优先推荐您尝试直连 IP(包括 v4 和 v6,哪个可用用哪个)。若没有出现高延迟或延迟波动严重的问题,尽量避免使用代理
IP(代理 IP 是流量计费的,使用会给服务器带来更大的经济压力)。
</p>
<p class="m-0 text-sm text-secondary">
若必须使用代理 IP,按以往经验,河北(含)以北的我国省份,在选用代理 IP 时建议优先选用北方代理,否则请使用南方代理。
</p>
</section>
</div>
<template #actions>
<div class="flex justify-end">
<ButtonStyled color="brand">
<button @click="hide">
<XIcon />
我知道了
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -61,7 +61,7 @@ const historyAnnouncements = computed(() =>
<UpdateAnnouncementContent
:announcement="currentAnnouncement"
:version="currentVersion"
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.website"
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.releasesUrl"
/>
</div>
@@ -102,7 +102,7 @@ const historyAnnouncements = computed(() =>
<UpdateAnnouncementContent
:announcement="announcement"
:show-header="false"
:external-url="announcement.externalUrl"
:external-url="announcement.externalUrl ?? `${AxolotlBrandConfig.releasesUrl}/tag/v${announcement.version}`"
/>
</div>
</Accordion>
@@ -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')
@@ -67,6 +67,7 @@ withDefaults(
}>(),
{
count: 1,
symlinkTarget: null,
},
)
@@ -127,7 +127,7 @@ interface IconBackground {
style: Record<string, string>
}
function background(
function makeBackground(
id: string,
name: MessageDescriptor,
top: string,
@@ -171,16 +171,16 @@ const backgrounds = [
backgroundSize: '16px 16px',
},
} satisfies IconBackground,
background('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
background('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
background('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
background('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
background('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
background('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
background('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
background('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
background('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
background('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
makeBackground('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
makeBackground('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
makeBackground('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
makeBackground('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
makeBackground('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
makeBackground('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
makeBackground('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
makeBackground('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
makeBackground('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
makeBackground('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
]
const { formatMessage } = useVIntl()
@@ -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,10 +22,11 @@ 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'
import { setUiScale, uiScale } from '@/helpers/ui-scale'
import { useTheming } from '@/store/state'
import {
type AccentColor,
@@ -40,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'
@@ -209,6 +223,15 @@ const messages = defineMessages({
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
uiScaleTitle: {
id: 'app.appearance-settings.ui-scale.title',
defaultMessage: 'Launcher scale',
},
uiScaleDescription: {
id: 'app.appearance-settings.ui-scale.description',
defaultMessage:
'Scale the whole launcher interface. A smaller scale fits more content, which helps when embedded pages are cut off.',
},
pageTransitionsTitle: {
id: 'app.appearance-settings.page-transitions.title',
defaultMessage: 'Page transition animations',
@@ -353,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())
@@ -363,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
@@ -473,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,
@@ -483,6 +643,8 @@ watch(
transparentBlur,
sidebarInstanceCount,
closeBehavior,
uiFont,
monoFont,
]) => {
themeStore.customBackgroundPath = path
themeStore.customBackgroundBlur = blur
@@ -493,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 () => {
@@ -1020,6 +1191,43 @@ watch(
/>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-ui-scale" tabindex="-1">
{{ formatMessage(messages.uiScaleTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.uiScaleDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex flex-wrap gap-2">
<button
v-for="preset in [100, 110, 125, 150]"
:key="preset"
type="button"
class="min-w-16 rounded-lg border border-solid px-3 py-1.5 text-sm font-semibold transition-colors"
:class="
uiScale === preset
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
"
@click="updateUiScale(preset)"
>
{{ preset }}%
</button>
</div>
<Slider
id="ui-scale"
:model-value="uiScale"
:min="50"
:max="200"
:step="5"
unit="%"
@update:model-value="(value) => updateUiScale(Number(value))"
/>
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-page-transitions" tabindex="-1">
@@ -1095,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>
@@ -12,13 +12,13 @@ import {
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { inject, nextTick, ref, watch } from 'vue'
import { nextTick, ref, watch } from 'vue'
import UpdateAnnouncementHistory from '@/components/ui/announcement/UpdateAnnouncementHistory.vue'
import {
betaDatabaseExists,
copyReleaseDatabaseToBeta,
copyDatabaseBetweenChannels,
copyReleaseDatabaseToBeta,
getCurrentAppDatabasePath,
getUpdateChannel,
getUpdatePreferences,
@@ -446,11 +446,14 @@ async function confirmDatabaseOperation() {
}),
})
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
handleError(
new Error(
targetChannel === activeChannel
? formatMessage(messages.databaseOperationActiveTarget)
: formatMessage(messages.databaseOperationFailed),
`${
targetChannel === activeChannel
? formatMessage(messages.databaseOperationActiveTarget)
: formatMessage(messages.databaseOperationFailed)
}: ${detail}`,
),
)
} finally {
@@ -468,7 +471,7 @@ function cancelDatabaseOperation() {
<div class="flex flex-col gap-6">
<SettingsSection :title="formatMessage(messages.title)">
<div class="update-channel-panel">
<p id="settings-target-updates-channel" class="m-0 text-sm leading-[1.45] text-secondary">
<p id="settings-target-updates-source" class="m-0 text-sm leading-[1.45] text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div
@@ -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>
@@ -1,4 +1,4 @@
import { defineMessage, type MessageDescriptor } from '@modrinth/ui'
import type { MessageDescriptor } from '@modrinth/ui'
export type SettingsCategoryId =
| 'interface'
@@ -28,100 +28,100 @@ export interface SettingsCategoryDefinition {
export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
{
id: 'interface',
name: defineMessage({
name: {
id: 'app.settings.tabs.interface',
defaultMessage: 'Interface & appearance',
}),
},
group: 'launcher',
onboardingId: 'settings-tab-interface',
},
{
id: 'home-navigation',
name: defineMessage({
name: {
id: 'app.settings.tabs.home-navigation',
defaultMessage: 'Home & navigation',
}),
},
group: 'launcher',
onboardingId: 'settings-tab-home-navigation',
},
{
id: 'language-translation',
name: defineMessage({
name: {
id: 'app.settings.tabs.language-translation',
defaultMessage: 'Language & translation',
}),
},
group: 'launcher',
onboardingId: 'settings-tab-language-translation',
},
{
id: 'ai',
name: defineMessage({ id: 'app.settings.tabs.ai', defaultMessage: 'AI' }),
name: { id: 'app.settings.tabs.ai', defaultMessage: 'AI' },
group: 'launcher',
flushContent: true,
onboardingId: 'settings-tab-ai',
},
{
id: 'java-performance',
name: defineMessage({
name: {
id: 'app.settings.tabs.java-performance',
defaultMessage: 'Java & performance',
}),
},
group: 'game',
onboardingId: 'settings-tab-java-performance',
},
{
id: 'launch-defaults',
name: defineMessage({
name: {
id: 'app.settings.tabs.launch-defaults',
defaultMessage: 'Launch & instance defaults',
}),
},
group: 'game',
onboardingId: 'settings-tab-launch-defaults',
},
{
id: 'content-downloads',
name: defineMessage({
name: {
id: 'app.settings.tabs.content-downloads',
defaultMessage: 'Content & downloads',
}),
},
group: 'game',
onboardingId: 'settings-tab-content-downloads',
},
{
id: 'network-multiplayer',
name: defineMessage({
name: {
id: 'app.settings.tabs.network-multiplayer',
defaultMessage: 'Network & multiplayer',
}),
},
group: 'game',
onboardingId: 'settings-tab-network-multiplayer',
},
{
id: 'storage-backups',
name: defineMessage({
name: {
id: 'app.settings.tabs.storage-backups',
defaultMessage: 'Storage & backups',
}),
},
group: 'data-privacy',
onboardingId: 'settings-tab-storage-backups',
},
{
id: 'updates',
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
name: { id: 'app.settings.tabs.updates', defaultMessage: 'Updates' },
group: 'support',
onboardingId: 'settings-tab-updates',
},
{
id: 'about',
name: defineMessage({ id: 'app.settings.tabs.about', defaultMessage: 'About' }),
name: { id: 'app.settings.tabs.about', defaultMessage: 'About' },
group: 'support',
},
{
id: 'feature-flags',
name: defineMessage({
name: {
id: 'settings.feature-flags.title',
defaultMessage: 'Feature flags',
}),
},
group: 'developer',
developerOnly: true,
},
@@ -15,7 +15,8 @@ import {
UsersIcon,
} from '@modrinth/assets'
import { commonMessages, defineMessages, type MessageDescriptor } from '@modrinth/ui'
import { defineAsyncComponent, type Component } from 'vue'
import { type Component, defineAsyncComponent } from 'vue'
import {
getVisibleSettingsCategoryDefinitions,
type SettingsCategoryDefinition,
@@ -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>
@@ -29,8 +29,8 @@ import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils'
import { handleSevereError } from '@/store/error'
@@ -1,120 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createLatestLogReader } from './latest-log-reader.ts'
test('follows file-only output after startup, re-entry, clear and file rotation', async () => {
let file = ''
let displayed = 'stdout startup'
const reader = createLatestLogReader(
async (cursor) => ({
cursor: file.length,
output: file.slice(cursor > file.length ? 0 : cursor),
new_file: cursor > file.length,
}),
(output, replace) => {
displayed = (replace ? '' : displayed) + output
},
)
await reader.refresh()
assert.equal(reader.active, false)
file = 'Startup complete\n'
await reader.refresh()
assert.equal(reader.active, true)
assert.equal(displayed, file) // Replaces stdout, not appended a second time.
file += 'Joined world\nDisconnected\n'
await reader.refresh()
await reader.refresh() // Re-entering the view must not replay history.
assert.equal(displayed, file)
reader.clear()
displayed = ''
file += 'After clear\n'
await reader.refresh()
assert.equal(displayed, 'After clear\n')
file = 'Rotated\n'
await reader.refresh()
assert.equal(displayed, file)
file = ''
await reader.refresh()
assert.equal(displayed, '')
assert.equal(reader.active, false)
file = 'File recreated\n'
await reader.refresh()
assert.equal(displayed, file)
reader.reset()
assert.equal(reader.active, false)
file = 'New session with a longer file\n'
await reader.refresh()
assert.equal(displayed, file)
})
test('serializes reads, ignores pre-restart responses, consumes pre-clear responses', async () => {
const resolvers: ((value: { cursor: number; output: string; new_file: boolean }) => void)[] = []
const cursors: number[] = []
const output: string[] = []
const reader = createLatestLogReader(
(cursor) => {
cursors.push(cursor)
return new Promise((resolve) => resolvers.push(resolve))
},
(text) => output.push(text),
)
const old = reader.refresh()
const concurrent = reader.refresh()
assert.deepEqual(cursors, [0])
reader.reset()
const fresh = reader.refresh()
resolvers[0]({ cursor: 100, output: 'old session', new_file: false })
await Promise.all([old, concurrent])
assert.deepEqual(output, [])
reader.clear()
resolvers[1]({ cursor: 20, output: 'before clear', new_file: false })
await fresh
assert.deepEqual(output, [])
const next = reader.refresh()
assert.deepEqual(cursors, [0, 0, 20])
resolvers[2]({ cursor: 40, output: 'after clear\n', new_file: false })
await next
assert.deepEqual(output, ['after clear\n'])
})
test('a temporary read failure keeps the cursor and retries without duplicating output', async () => {
let failed = true
const output: string[] = []
const reader = createLatestLogReader(
async (cursor) => {
assert.equal(cursor, 0)
if (failed) throw new Error('file temporarily unavailable')
return { cursor: 6, output: 'ready\n', new_file: false }
},
(text) => output.push(text),
)
await assert.rejects(reader.refresh())
assert.equal(reader.active, false)
failed = false
await reader.refresh()
assert.deepEqual(output, ['ready\n'])
})
test('joins split lines and flushes the final unterminated line on exit', async () => {
let chunk = 'Startup\n[Render thread/INFO] Join'
let cursor = 0
let displayed = ''
const reader = createLatestLogReader(
async () => ({
cursor: (cursor += chunk.length),
output: chunk,
new_file: false,
}),
(text, replace) => {
displayed = (replace ? '' : displayed) + text
},
)
await reader.refresh()
assert.equal(displayed, 'Startup\n')
chunk = 'ed world\nLast line'
await reader.refresh()
assert.equal(displayed, 'Startup\n[Render thread/INFO] Joined world\n')
reader.flush()
assert.equal(displayed, 'Startup\n[Render thread/INFO] Joined world\nLast line')
})
@@ -1,88 +0,0 @@
interface LogCursor {
cursor: number
output: string
new_file: boolean
}
/** One authoritative stream: use process events until latest.log becomes available. */
export function createLatestLogReader(
read: (cursor: number) => Promise<LogCursor>,
append: (output: string, replace: boolean) => void,
) {
let cursor = 0
let active = false
let session = 0
let cleared = 0
let partialLine = ''
let replaceOnAppend = false
let pending: Promise<void> | null = null
function refresh(): Promise<void> {
if (pending) return pending
const requestSession = session
const requestClear = cleared
const request = (async () => {
const result = await read(cursor)
if (requestSession !== session) return
const rotated = result.new_file || result.cursor < cursor
const replace = !active || rotated
cursor = result.cursor
if (result.cursor === 0 && !result.output) {
if (rotated) {
active = false
partialLine = ''
replaceOnAppend = false
if (requestClear === cleared) append('', true)
}
return
}
active = true
// Clearing during a read consumes its bytes without restoring cleared text.
if (requestClear === cleared && (result.output || result.new_file)) {
if (replace) {
partialLine = ''
replaceOnAppend = true
}
partialLine += result.output
// File writes can end mid-line. Do not turn the next chunk into a
// separate log entry; bound unterminated output just like the console.
const end =
partialLine.length > 64 * 1024 ? partialLine.length : partialLine.lastIndexOf('\n') + 1
if (end > 0) {
append(partialLine.slice(0, end), replaceOnAppend)
partialLine = partialLine.slice(end)
replaceOnAppend = false
}
}
})()
pending = request
return request.finally(() => {
if (pending === request) pending = null
})
}
return {
refresh,
get active() {
return active
},
clear() {
cleared++
partialLine = ''
replaceOnAppend = false
},
flush() {
if (partialLine) append(partialLine, replaceOnAppend)
partialLine = ''
replaceOnAppend = false
},
reset() {
session++
cursor = 0
active = false
partialLine = ''
replaceOnAppend = false
pending = null
},
}
}
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createLiveLogReconciler,
type LiveLogSnapshot,
} from './live-log-reconciler.ts'
test('merges the authoritative snapshot with queued events without gaps or duplicates', async () => {
let resolveSnapshot!: (snapshot: LiveLogSnapshot) => void
const displayed: number[] = []
const reconciler = createLiveLogReconciler(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve
}),
(snapshot) => {
displayed.splice(
0,
displayed.length,
...snapshot.lines.map((line) => line.sequence),
)
},
(event) => displayed.push(event.last_sequence),
)
const syncing = reconciler.sync()
reconciler.accept({ first_sequence: 3, last_sequence: 3 })
resolveSnapshot({
lines: [
{ sequence: 1, message: 'one' },
{ sequence: 2, message: 'two' },
],
last_sequence: 2,
})
await syncing
assert.deepEqual(displayed, [1, 2, 3])
await reconciler.accept({ first_sequence: 3, last_sequence: 3 })
assert.deepEqual(displayed, [1, 2, 3])
})
test('resynchronizes when an event sequence reveals a missed batch', async () => {
const displayed: number[] = []
let loads = 0
const reconciler = createLiveLogReconciler(
async () => {
loads++
return {
lines: [1, 2, 3, 4].map((sequence) => ({
sequence,
message: String(sequence),
})),
last_sequence: 4,
}
},
(snapshot) => {
displayed.splice(
0,
displayed.length,
...snapshot.lines.map((line) => line.sequence),
)
},
(event) => displayed.push(event.last_sequence),
)
await reconciler.accept({ first_sequence: 4, last_sequence: 4 })
assert.equal(loads, 1)
assert.deepEqual(displayed, [1, 2, 3, 4])
})
@@ -0,0 +1,81 @@
export interface LiveLogSnapshot {
lines: Array<{ sequence: number; message: string }>
last_sequence: number
}
export interface SequencedLogEvent {
first_sequence: number
last_sequence: number
}
export function createLiveLogReconciler<T extends SequencedLogEvent>(
loadSnapshot: () => Promise<LiveLogSnapshot>,
replace: (snapshot: LiveLogSnapshot) => void,
append: (event: T) => void,
) {
let lastSequence = 0
let queued: T[] = []
let syncing: Promise<void> | null = null
let disposed = false
function drainQueued(): boolean {
const events = queued
queued = []
for (let index = 0; index < events.length; index++) {
const event = events[index]!
if (event.last_sequence <= lastSequence) continue
if (event.first_sequence > lastSequence + 1) {
queued.push(...events.slice(index))
return false
}
append(event)
lastSequence = event.last_sequence
}
return true
}
function sync(): Promise<void> {
if (syncing) return syncing
const request = (async () => {
do {
const snapshot = await loadSnapshot()
if (disposed) return
replace(snapshot)
lastSequence = snapshot.last_sequence
} while (!drainQueued())
})()
const completion = request.finally(() => {
if (syncing === completion) syncing = null
})
syncing = completion
return completion
}
function resync(): Promise<void> {
return syncing ? syncing.then(sync) : sync()
}
function accept(event: T): Promise<void> | undefined {
if (disposed || event.last_sequence <= lastSequence) return
if (syncing) {
queued.push(event)
return syncing
}
if (event.first_sequence > lastSequence + 1) {
queued.push(event)
return sync()
}
append(event)
lastSequence = event.last_sequence
}
return {
accept,
resync,
sync,
dispose() {
disposed = true
queued = []
},
}
}
@@ -1,21 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useHostedSync } from './useHostedSync.ts'
import { useHostedCreation, forgetHostedCreation } from './useHostedCreation.ts'
import {
clearHostedSession,
HOSTED_ACCOUNT_REQUIRED_MESSAGE,
hostedCreate,
hostedDefault,
hostedSync,
onHostedPackAttemptStarted,
setInstanceMode,
} from '../helpers/hosted-packs.ts'
import {
openSkinSiteLogin,
receiveSkinSiteMessage,
requestSkinSiteLuck,
requestSkinSiteDownloadToken,
requestSkinSiteLuck,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
resetSkinSiteSession,
@@ -27,6 +26,8 @@ import {
skinSiteStatus,
skinSiteUser,
} from './skin-site-session.ts'
import { forgetHostedCreation, useHostedCreation } from './useHostedCreation.ts'
import { useHostedSync } from './useHostedSync.ts'
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
const frame = { postMessage() {} } as unknown as Window
@@ -46,6 +47,10 @@ test('hosted installation sends the JWT to native commands while Local needs no
let createdId = 'instance'
let instanceExists = false
let tokenRequests = 0
let accounts: Array<{
account_type: string
yggdrasil?: { api_root: string }
}> = [{ account_type: 'microsoft' }]
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type !== 'starlight-pack-token-request') return
@@ -70,6 +75,14 @@ test('hosted installation sends the JWT to native commands while Local needs no
__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:install|hosted_sync' && syncResponse) return syncResponse()
if (command === 'plugin:instance|instance_get')
return instanceExists ? { id: args.instanceId } : null
@@ -99,7 +112,13 @@ test('hosted installation sends the JWT to native commands while Local needs no
await setInstanceMode('instance', 'starlight')
assert.equal(tokenRequests, 4)
assert.deepEqual(
calls.map(({ command }) => command),
calls
.map(({ command }) => command)
.filter(
(command) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
),
[
'hosted_set_session',
'hosted_default',
@@ -210,9 +229,35 @@ test('hosted installation sends the JWT to native commands while Local needs no
assert.equal(tokenRequests, requestsBeforeLogout)
const attempts: string[] = []
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
accounts = []
const installCallsBeforeAccountFailure = calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length
await assert.rejects(hostedSync('failed-instance'), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
await assert.rejects(hostedCreate(), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
assert.deepEqual(attempts, [])
assert.equal(
calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length,
installCallsBeforeAccountFailure,
)
const unauthenticatedCreation = useHostedCreation()
await unauthenticatedCreation.install()
assert.equal(unauthenticatedCreation.installError.value, HOSTED_ACCOUNT_REQUIRED_MESSAGE)
assert.equal(unauthenticatedCreation.createdInstance.value, undefined)
accounts = [{ account_type: 'microsoft' }]
const unauthenticatedRetry = hostedSync('failed-instance')
assert.deepEqual(attempts, ['failed-instance'])
await assert.rejects(unauthenticatedRetry)
assert.deepEqual(attempts, ['failed-instance'])
stopListening()
await assert.rejects(hostedSync('failed-instance'))
assert.equal(attempts.length, 1)
@@ -560,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
}
@@ -13,9 +13,9 @@ import type {
SymlinkMethodChoice,
} from '@modrinth/ui'
import { useDebugLogger, useGlobalDrop, useInstanceContext, useVIntl } from '@modrinth/ui'
import { join } from '@tauri-apps/api/path'
import { computed, type ComputedRef, nextTick, ref } from 'vue'
import type { Router } from 'vue-router'
import { join } from '@tauri-apps/api/path'
import {
classifyDroppedItem,
@@ -1412,10 +1412,7 @@ export function useDropImport(options: DropImportOptions) {
cleanupLauncherZipTemp()
}
function resolvedInstancePath(
inst: SelectedInstance,
ctx: ImportContext | null,
): string | undefined {
function resolvedInstancePath(inst: SelectedInstance): string | undefined {
if (inst.compatibleMode) return inst.versionPath
return inst.path
}
@@ -10,6 +10,10 @@ const createdInstance = ref<string>()
const completed = ref(false)
let generation = 0
function errorMessage(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause)
}
export function forgetHostedCreation(instanceId: string) {
if (createdInstance.value !== instanceId) return
generation++
@@ -27,7 +31,7 @@ export function markHostedCreationCompleted(instanceId: string) {
export function markHostedCreationFailed(instanceId: string, cause: unknown) {
if (createdInstance.value !== instanceId) return
completed.value = false
installError.value = String(cause)
installError.value = errorMessage(cause)
}
export function useHostedCreation() {
@@ -63,7 +67,7 @@ export function useHostedCreation() {
} catch (cause) {
if (attempt === generation) {
if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause)
else installError.value = String(cause)
else installError.value = errorMessage(cause)
}
} finally {
installing.value = false
@@ -2,12 +2,14 @@ import { createConsoleState } from '@modrinth/ui'
import {
clear_log_buffer,
get_live_log_buffer,
get_live_log_snapshot,
get_logs,
get_minecraft_latest_log_cursor,
} from '@/helpers/logs'
import { createLatestLogReader } from './latest-log-reader'
import {
createLiveLogReconciler,
type SequencedLogEvent,
} from './live-log-reconciler'
type ConsoleState = ReturnType<typeof createConsoleState>
@@ -25,8 +27,17 @@ interface InstanceConsoleEntry {
historicalConsole: ConsoleState
historicalCache: Map<string, string>
logList: LogEntry[] | null
liveHistoryHydration: Promise<void> | null
latestLog: ReturnType<typeof createLatestLogReader>
liveLog: ReturnType<typeof createLiveLogReconciler<ProcessLogPayload>>
}
interface ProcessLogPayload extends SequencedLogEvent {
type: 'log4j' | 'legacy'
message?: string
logger_name?: string
level?: string
thread_name?: string
timestamp_millis?: number
throwable?: string
}
const instances = new Map<string, InstanceConsoleEntry>()
@@ -36,19 +47,28 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
if (entry) return entry
const liveConsole = createConsoleState()
const liveLog = createLiveLogReconciler<ProcessLogPayload>(
() => get_live_log_snapshot(instanceId),
(snapshot) => {
liveConsole.clear()
void liveConsole.addLegacyLog(
snapshot.lines.map((line) => line.message).join('\n'),
)
},
(payload) => {
if (payload.type === 'log4j') {
liveConsole.addLog4jEvent(payload)
} else if (payload.message !== undefined) {
void liveConsole.addLegacyLog(payload.message)
}
},
)
entry = {
liveConsole,
historicalConsole: createConsoleState(),
historicalCache: new Map(),
logList: null,
liveHistoryHydration: null,
latestLog: createLatestLogReader(
(cursor) => get_minecraft_latest_log_cursor(instanceId, cursor),
(output, replace) => {
if (replace) liveConsole.clear()
void liveConsole.addLegacyLog(output)
},
),
liveLog,
}
instances.set(instanceId, entry)
return entry
@@ -56,28 +76,7 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
async function hydrate(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
if (entry.liveHistoryHydration) {
return entry.liveHistoryHydration
}
const hydration = (async () => {
await entry.latestLog.refresh().catch(console.warn)
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
const buffer = await get_live_log_buffer(instanceId)
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
await entry.liveConsole.addLegacyLog(buffer)
}
}
})()
entry.liveHistoryHydration = hydration
try {
await hydration
} finally {
if (entry.liveHistoryHydration === hydration) {
entry.liveHistoryHydration = null
}
}
await entry.liveLog.sync()
}
async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> {
@@ -109,15 +108,13 @@ function invalidate(instanceId: string): void {
async function clearLive(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
entry.latestLog.clear()
entry.liveConsole.clear()
await clear_log_buffer(instanceId).catch(() => {})
await clear_log_buffer(instanceId)
await entry.liveLog.resync()
}
async function destroy(instanceId: string): Promise<void> {
instances.get(instanceId)?.latestLog.reset()
function destroy(instanceId: string): void {
instances.get(instanceId)?.liveLog.dispose()
instances.delete(instanceId)
await clear_log_buffer(instanceId).catch(() => {})
}
export function useInstanceConsole(instanceId: string) {
@@ -130,13 +127,8 @@ export function useInstanceConsole(instanceId: string) {
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
invalidate: () => invalidate(instanceId),
clearLive: () => clearLive(instanceId),
refreshLive: () => entry.latestLog.refresh(),
flushLive: () => entry.latestLog.flush(),
acceptsProcessLogs: () => !entry.latestLog.active,
resetLive: () => {
entry.latestLog.reset()
entry.liveConsole.clear()
},
appendLive: (payload: ProcessLogPayload) => entry.liveLog.accept(payload),
resyncLive: () => entry.liveLog.resync(),
destroy: () => destroy(instanceId),
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ export const AxolotlBrandConfig = Object.freeze({
productName: 'Starlight Launcher',
shortProductName: 'Starlight',
website: 'https://skin.starlight.cool/',
repositoryUrl: 'https://git.starlight.cool/Disy/StarLight-Yggdrasil-Server',
repositoryUrl: 'https://github.com/ApTx2012/Starlight_Lancher',
releasesUrl: 'https://github.com/ApTx2012/Starlight_Lancher/releases',
supportUrl: 'https://skin.starlight.cool/',
qqGroupNumber: '000000000',
qqChannelUrl: 'https://skin.starlight.cool/',
@@ -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,
})
}
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
+45 -3
View File
@@ -1,10 +1,16 @@
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 = '您需要登录正版或皮肤站账号后才能操作'
let sessionUpdate: Promise<void> = Promise.resolve()
const attemptListeners = new Set<(instanceId: string) => void>()
@@ -20,6 +26,39 @@ function startHostedPackAttempt(instanceId: string) {
for (const listener of attemptListeners) listener(instanceId)
}
export async function requireHostedGameAccount(): Promise<void> {
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> {
const update = sessionUpdate
.catch(() => {})
@@ -99,13 +138,16 @@ export interface HostedSyncResult {
}
export const hostedDefault = () =>
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
export const hostedCreate = (gameDirRoot?: string | null) =>
invokeWithSession<string>('plugin:install|hosted_create', {
export const hostedCreate = async (gameDirRoot?: string | null) => {
await requireHostedGameAccount()
return invokeWithSession<string>('plugin:install|hosted_create', {
gameDirRoot: gameDirRoot ?? null,
})
}
export const hostedBinding = (instanceId: string) =>
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const hostedSync = (instanceId: string) => {
export const hostedSync = async (instanceId: string) => {
await requireHostedGameAccount()
startHostedPackAttempt(instanceId)
return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId })
}
@@ -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
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { isInstanceLaunching, launchOnce } from './instance-launch-state.ts'
test('launches different instances concurrently and coalesces repeated pending clicks', async () => {
@@ -1,18 +1,19 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
import {
setSkinSiteFrame,
resetSkinSiteSession,
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
} from '../composables/skin-site-session.ts'
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
import {
type InstancePlayer,
onInstancePlayerChanged,
prepareInstancePlayer,
registerInstancePlayerPicker,
saveInstancePlayer,
onInstancePlayerChanged,
type InstancePlayer,
} from './instance-player.ts'
test('instance player selection persists independently from the globally selected account', async () => {
+187 -2
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'
@@ -18,8 +18,8 @@ import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategi
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { launchOnce } from '@/helpers/instance-launch-state'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
import { get_jre, get_memory_status } from '@/helpers/jre.js'
import { get as getSettings } from '@/helpers/settings'
@@ -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
}
+3 -8
View File
@@ -64,14 +64,9 @@ export async function get_latest_log_cursor(instanceId, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { instanceId, cursor })
}
/// Read Minecraft's logs/latest.log from a cursor.
export async function get_minecraft_latest_log_cursor(instanceId, cursor) {
return await invoke('plugin:logs|logs_get_minecraft_latest_log_cursor', { instanceId, cursor })
}
/// Get all buffered live log lines for an instance from the Rust ring buffer
export async function get_live_log_buffer(instanceId) {
return await invoke('plugin:logs|logs_get_live_log_buffer', { instanceId })
/// Get the authoritative sequenced live-log snapshot from the Rust ring buffer.
export async function get_live_log_snapshot(instanceId) {
return await invoke('plugin:logs|logs_get_live_log_snapshot', { instanceId })
}
/// Clear the live log buffer for an instance on the Rust side
Loaded 100 of 477 files, more files were not shown because too many files have changed in this diff. Show more