Compare commits

..
140 Commits
Author SHA1 Message Date
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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Axolotl→Starlight 的跨实例同步移植,含后端命令层 + 前端 UI。
2026-10-04 10:59:58 +08:00
AxTps f73bc89f84 fix: 修复 CI 的 rustfmt 与 typos 报错
Sync source to CNB / Sync Git ref (push) Has been cancelled
为 instance 同步命令定义之间补空行并补齐文件末尾换行;将 game-settings-modal 的 languages.ts(Minecraft 语言名数据)加入 typos 排除,避免语言代码被误判为拼写错误。
2026-10-04 09:23:04 +08:00
AxTps 4f220201b3 feat: 跨实例同步移植第9轮(游戏设置编辑器)
Sync source to CNB / Sync Git ref (push) Has been cancelled
搬入 game-settings-modal(游戏选项精细编辑,10 文件):设置行编辑器、按键绑定、语言选择与文案表。新增 app-settings-modal provider 与 instance-settings-context 转发,并把 instance-settings 的 closeModal 改为支持关闭回调。SyncSettings 恢复游戏设置入口。补齐 game-options 相关 en-US / zh-CN 文案。
2026-10-04 09:18:33 +08:00
AxTps 83ee962c16 feat: 跨实例同步移植第8轮(前端 helpers + 同步设置 UI)
Sync source to CNB / Sync Git ref (push) Has been cancelled
新增同步 helpers(synced-options / synced-packs / game-options),在 instance helper 中补充同步类型与 API。新增实例设置「同步」标签页(SyncSettings)及 SyncSource / SyncedPacks / servers / command-history 四个弹窗,并把 CheckCircleButton、TeleportOverflowMenu 从 ui 包导出。同步相关的 en-US / zh-CN 文案已补齐(各 62 条)。game-settings-modal 留待下一轮。
2026-10-04 08:51:45 +08:00
AxTps f7380f1590 feat: 跨实例同步移植第7轮(命令注册 + ACL)
Sync source to CNB / Sync Git ref (push) Has been cancelled
在 apps/app 的 instance 插件注册 27 个同步命令(synced_options / synced_servers / synced_packs / game_options),并在 build.rs 补齐对应 ACL 权限。同时在 app-lib 的 api/instance 模块导出 InstanceSyncedOptions 与 SyncedOption,修复命令层 theseus::instance::InstanceSyncedOptions 的解析路径。
2026-10-04 08:37:39 +08:00
AxTps 121da431ed feat: 跨实例同步移植 local_settings_editor(2c 收尾)
Sync source to CNB / Sync Git ref (push) Has been cancelled
- local_settings_editor.rs:单实例(未同步)游戏设置的读取/预览/保存

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- synced_servers:6 文件全部搬入

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

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

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

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

- instance_rows 新增 get_instance_synced_options

- 前端 GameInstance 增加 synced_options 类型
2026-10-03 21:08:05 +08:00
AxTps 91e63fdb2c fix: 登录皮肤站后自动认证默认玩家,修复创建实例误报需要登录
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Axolotl desktop CI / desktop (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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-03 10:08:43 +08:00
AxTps a99a0a6e30 feat(backup): 实例备份第5轮——新增备份设置/排除选择器/仓库设置组件,接入实例设置与存储设置页,补齐中英繁文案
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 09:43:57 +08:00
AxTps 773cf387a3 feat(backup): 实例备份第4轮——新增前端 helper(instance-backup.ts)及单元测试 2026-10-03 09:37:41 +08:00
AxTps 7414477bbd feat(backup): 实例备份第3轮——注册 15 个 Tauri 备份命令并补齐 ACL 权限 2026-10-03 09:32:46 +08:00
AxTps e8f10ab1ef feat(backup): 实例备份第2轮——移植 backup.rs 核心实现并接入 instance 模块导出 2026-10-03 09:19:28 +08:00
AxTps e91ba2ae74 feat(backup): 实例备份第1轮——独立备份库迁移、settings 仓库路径字段、备份进度事件 2026-10-03 09:12:35 +08:00
AxTps 95a3e00b03 feat(fonts): 外观设置新增界面/等宽字体选择器,含中英繁中文案
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 23:04:12 +08:00
AxTps f490600ee2 feat(fonts): 前端字体工具函数、主题字体状态与设置类型 2026-10-02 21:57:24 +08:00
AxTps b57219f348 feat(fonts): 后端字体命令 fonts_get_system_fonts 及 ui_font/mono_font 设置字段 2026-10-02 21:33:13 +08:00
AxTps ead942abb2 feat: 移植忽略 SSL 证书错误选项(设置开关 + 重建 HTTP 客户端)
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
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 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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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 / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (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
Sync LobeHub models / sync (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 11:03:59 +08:00
AxTps fbde37f5a0 fix: 补全新增 command 的 Tauri 权限
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
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 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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Axolotl desktop CI / desktop (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (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
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 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
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 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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Axolotl desktop CI / desktop (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 / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Axolotl desktop CI / desktop (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
Sync LobeHub models / sync (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
- catalog.ts 增量新增 1.0.1-beta5 公告(重做导览、功能开关修复、
  编译错误修复);
- 网站 catalog.json 重新生成。
2026-10-01 09:35:05 +08:00
AxTps c7d1e44719 feat: 重做新手导览
按新的顺序重写 main 导览(10 个内容点,导航 + 高亮共 16 步):
主页、登录 StarLight、侧栏公告、创建 StarLight 实例、发现内容、
皮肤选择器、实例库、实验室、下载、设置的更新页。

- onboardingConfig.ts 重写 onboardingTours.main,并新增 home-intro/
  account-login/announcement/create-starlight/settings-updates 文案;
- HomeAnnouncementEntry.vue 增加 data-onboarding-id="home-announcement"
  锚点;
- en-US / zh-CN 补齐上述新增文案。
2026-10-01 09:21:39 +08:00
AxTps 0a6fa0eb35 chore(ci): tombi 改用 npx 安装
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Axolotl desktop CI / desktop (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (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
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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Axolotl desktop CI / desktop (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
Sync LobeHub models / sync (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
- catalog.ts 增量新增 1.0.1-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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (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
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
将仓库、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
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
- 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
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (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
AxTps e1cca236ee chore: 更新 Tauri updater 签名公钥
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
重新生成签名密钥对(原私钥已丢失),更新 tauri-release.conf.json
中的 updater pubkey 以匹配新的私钥。新私钥用于 GitHub Actions 的
TAURI_SIGNING_PRIVATE_KEY secret。

注意:更换公钥后,使用旧公钥构建的客户端将无法验证新签名,需要
手动升级到使用新密钥构建的版本。
2026-09-25 22:44:19 +08:00
AxTps 2edf079724 chore: 统一版本公告为 1.0.0-beta12
将 1.0.1-beta1 的版本公告改为 1.0.0-beta12,与合并后上游采用的
1.0.0-betaN 版本序列保持一致。
2026-09-25 22:34:42 +08:00
AxTps 6ce39f3a42 Merge branch 'main' of https://github.com/ApTx2012/Starlight_Lancher
# Conflicts:
#	.github/workflows/build-on-tag.yml
#	.github/workflows/sync-cnb-source.yml
2026-09-25 22:29:40 +08:00
AxTps 6e7f982ee6 Merge branch 'main' of https://git.starlight.cool/aptxyyds/Starlight_Lancher
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
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-25 22:22:07 +08:00
AxTps a8c630cbc8 chore(ci): Release 带更新说明并移除 CNB 源码镜像
- build-on-tag.yml 改为三段式:preflight 从 announcement catalog
  生成中英双语更新说明并创建带说明的 draft release,build 各平台
  仅上传产物,publish 在所有平台完成后把 draft 转为正式发布。
- 移除 sync-cnb-source.yml:不再向 CNB 镜像源码(避免打 tag 时
  触发额外流水线,以及默认上游仓库带来的误推风险)。
- catalog.ts 新增 1.0.1-beta1 版本公告,作为该版本 Release 说明
  的来源。
2026-09-25 22:19:19 +08:00
AxTps bb55230c3a chore(ci): 打 tag 自动编译并发布 Release
新增 build-on-tag.yml:推送 v* tag 时在 4 个平台(Linux x64/arm64、
Windows x64、macOS universal)编译,并通过 tauri-action 直接发布
GitHub Release,包含各平台安装包与 Windows 便携版/多安装器变体。

相比原 axolotl-release.yml 移除了与自动更新相关的后处理步骤
(生成/上传更新清单、通知 Update Server、CNB 镜像、网站下载元数据),
仅保留“编译 + 发布 Release”这一核心目标;相关脚本仍保留在仓库中,
供后续接入自动更新时复用。

同时移除原 axolotl-release.yml,避免两个 workflow 同时响应 v* tag
造成的重复构建与产物冲突。
2026-09-25 22:01:31 +08:00
Disy 1019dcf478 fix: restore executable permission for Gradle on Linux and macOS 2026-09-25 14:53:29 +08:00
Disy b14c50c2f1 fix: align dependency lockfile with pinned pnpm patch configuration 2026-09-25 14:19:54 +08:00
Disy 1cf67602d8 fix: support concurrent game windows and prepare beta12 GitHub releases 2026-09-25 11:49:41 +08:00
Disy 91f483b960 fix: 修复内容筛选即时切换并将警告改为仅客户端 2026-09-24 23:44:40 +08:00
Disy 19098f7159 fix: follow live logs and reconcile duplicate managed mods
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-09-24 22:36:21 +08:00
Disy c2bc7c2441 fix: 修复实例导入与日志路径的 Rust 类型问题
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
实例导入时先保存相对路径,再移动完整路径,避免借用与移动冲突。
日志帧处理时将通用路径参数转换为 Path 引用,匹配处理函数的参数类型。
忽略本地 .tmp 目录中的下载包、第三方仓库和验证日志。
2026-09-24 14:18:00 +08:00
Disy e4fa234df1 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
侧栏直接合并皮肤站角色与已保存账户并按 UUID 去重,无需先启动实例。
选择角色时才进行认证并保存默认账户,认证失败时保留原选择并允许重试。
移除逐个预认证角色的流程,修复无皮肤玩家的默认头像引用异常。

补充组件回归测试,覆盖首次登录、默认玩家切换、失败重试、退出登录和无皮肤角色。
验证:14 项相关测试及 ESLint 通过,前端生产构建通过。
全量检查仍有既有的设置搜索、升级结果组件测试失败及类型错误。
2026-09-24 14:15:00 +08:00
AxTps 42b0c7e7af fix: 修复未登录皮肤站时启动卡住且不再弹玩家选择
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
未登录皮肤站时启动游戏会弹窗要求选择/登录玩家。若点击“登录皮肤站”
跳转后不登录直接返回,再次启动不会重新弹窗,而是卡在“正在启动”。

原因:InstancePlayerModal 的 signInSkinSite 跳转登录时只隐藏弹窗,
没有 settle 玩家选择的 Promise,因此 prepareInstancePlayer 在
preparing 表中留下的任务永不结束。再次启动时复用了这个悬空任务,
既不再弹窗也无法继续启动。

修复:
- instance-player 新增哨兵错误 PlayerSelectionNavigatedAwayError。
- signInSkinSite 跳转登录前以该错误 reject 当前选择,使
  prepareInstancePlayer 的任务正常结束、清理 in-flight 记录,下次
  启动会重新弹出玩家选择。
- App.vue 的启动错误处理识别该哨兵:跳转登录是用户主动操作而非启动
  失败,静默中止启动,不弹错误提示。
2026-09-19 20:01:46 +08:00
AxTps 405d980311 fix: 登录皮肤站后侧栏立即显示玩家选择器
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
登录 StarLight 皮肤站后,侧栏账户选择器(AccountsCard)不会列出
皮肤站玩家,必须先使用该玩家启动一次实例,玩家才会出现。

原因:账户选择器列出的是 auth::get_users 的账户库,而皮肤站玩家
是 iframe 推送的另一套数据。两者唯一的打通点是
auth|login_skin_site_player(把玩家注册为 yggdrasil 账户),而它
此前只在启动实例选择玩家时被调用。

修复:
- instance-player 新增 registerSkinSitePlayers:玩家列表就绪后,
  把尚未注册的皮肤站玩家逐个注册为账户。幂等(跳过账户库中已存在
  的 profile id)、best-effort(单个失败不影响其余)、每个玩家
  单独获取下载 token(皮肤站登录接口可能将 token 绑定到单一玩家)。
- AccountsCard 的玩家监听新增 skinSiteUser 依赖,玩家就绪后调用
  注册并刷新账户列表,使玩家无需先启动实例即可在选择器中选择。
2026-09-19 19:51:42 +08:00
AxTps 905e905eca fix: 修复 SLS 实例删除残留外部数据与重装共用文件夹
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
删除实例残留外部数据:
- remove_instance 原先只在 game_dir_override 指向 versions/<name>
  隔离目录时才删除外部目录;SLS(hosted)实例使用版本共享布局
  (外部目录直接作为游戏根),删除时落到托管目录分支,外部 mods/
  存档/配置残留。
- 现在 game_dir_override 只要指向实例独占目录(非共享 .minecraft
  根)就一并删除;新增 is_shared_minecraft_root 判据:含
  libraries/ 或 assets/ 的目录视为共享游戏根,删除实例时保留。

重装共用同一文件夹:
- hosted::create 生成 game_dir_override 时直接拼接
  `<root>/<pack name>`,无冲突处理;同一整合包安装两次会指向同一
  目录,两个实例共用一份游戏数据。
- 新增 unique_game_dir:目标目录已存在时依次尝试 `<name> (1)`、
  `<name> (2)` …,与 create_instance::resolve_instance_path 的
  实例目录去重逻辑保持一致。
2026-09-19 19:37:08 +08:00
AxTps cda7bb284a 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
日志停更(实例启动后日志不再刷新):
- process.rs 的 XML 日志分支原先使用 quick-xml 流式异步 Reader。
  该解析器在任何 I/O/解析错误或瞬时 Eof 后会把内部状态永久置为
  ParseState::Done(quick-xml #513),此后永远只返回 Eof,导致
  日志转发在客户端启动、日志量变大后彻底停止。
- 改为把原始字节累积到缓冲区,切出完整的 <log4j:Event …>…
  </log4j:Event> 帧后逐帧同步解析;坏帧/分片帧只跳过,不再毒化
  解析状态,日志转发持续进行。
- 新增 take_next_log4j_frame(切帧)与 handle_log4j_frame(单帧
  解析与转发)两个辅助函数。

日志行重叠:
- LogViewport.vue 原先用手动虚拟滚动,按估算高度为每行设置固定
  height;估算与实际渲染高度不符(高亮、wrap 折行等)时,绝对
  定位的行会溢出并相互重叠。
- 改用原生虚拟化 content-visibility: auto + contain-intrinsic-size,
  全量渲染由浏览器跳过屏外行的布局绘制,进入视口时以真实高度
  修正,从根本上消除估算误差导致的重叠。
- 保留 scrollToBottom / 自动跟随底部 / 搜索高亮 / 错误警告配色
  等既有行为与对外接口。
2026-09-19 19:13:59 +08:00
AxTps ffe529df57 Merge branch 'main' of https://git.starlight.cool/aptxyyds/Starlight_Lancher
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-19 18:52:08 +08:00
AxTps 984f3ddbd6 fix: 修复从 .minecraft 导入实例的多项缺陷
修复从 .minecraft / PCL / HMCL 等启动器导入实例时的 6 个问题:

- 复制模式不再克隆 versions/ 下的所有实例,只保留选中的版本
- 版本隔离导入时把 .minecraft 根内容(mods/saves/config)与选中
  versions/<name> 合并进实例目录,不再得到纯原版实例
- 符号链接导入不再因 game_dir_override 被丢弃而在错误目录创建
  游戏数据,实例也不会在失败后被误删
- 不再将用户选择的版本隔离模式强行改为共享(InstallRequest::
  ImportInstance 的 game_dir_override 现在正确透传到导入逻辑)
- 清洗非标准加载器坐标(如 net.neoforged:neoforge:21.1.250:client]
  中的 :client] 后缀),并补充回归测试

涉及 api/pack/import 下的 generic/mod/instance_json 及各启动器
导入器,以及 install/runner 的调用点。
2026-09-19 18:49:55 +08:00
Disy a477b1fb9a feat: integrate StarLight updates and improve font settings and skin editor loading
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-19 18:14:41 +08:00
Disy a47d309103 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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-09-19 00:13:43 +08:00
Disy 392f01ea4b fix: 允许完整编辑实例名称
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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-18 23:45:58 +08:00
Xiao-no-love 5006f432dc feat: 一键安装支持自选游戏目录
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
StarLight 实例一键安装此前固定落到启动器默认目录,无法选择游戏数据位置。
现复用创建流程的外部游戏目录能力:点击一键安装后弹出目录选择框,
只允许修改游戏目录,其余参数仍由服务器整合包决定。

- hosted::create 接受 game_dir_root,拼为 <root>/<包名> 作为 game_dir_override
  (刻意避开 versions/ 布局,该结构被外部直链实例检测占用)
- 新增顶层命令 get_launcher_root_dir,作为弹窗默认值(可执行文件所在目录)
- 新增 HostedGameDirModal 弹窗:只读路径框 + 浏览 + 默认位置 + 安装预览
- 补齐 en-US / zh-CN / zh-TW 三语文案

hosted_create 增加 game_dir_root 参数,前端 hostedCreate/install 同步透传。
2026-09-18 13:04:28 +08:00
Disy 51fb0c30f7 fix: 完善整合包同步与启动器交互
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-09-16 17:01:22 +08:00
Disy bd09fed0ed fix: improve parallel pack downloads and suppress stale notifications
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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Download hosted pack files concurrently, recover file transfers from proxy and content-encoding failures, and prevent old installation failures from reappearing in notifications.
2026-09-15 21:14:18 +08:00
Disy bc904065c3 feat: complete hosted mod sync and launcher interface updates
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
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
2026-09-15 19:06:56 +08:00
Disy 5d473ebfbc feat: 关于页接入凋灵胜利循环动画
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Axolotl desktop CI / guardrails (push) Has been cancelled
Sync source to CNB / Sync Git ref (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
Sync LobeHub models / sync (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
将关于页顶部的旧展示替换为已确认的凋灵胜利场景,加入像素月亮、方块云、下界之星浮动旋转、附魔闪光、护盾闪烁和消散粒子。

素材随启动器本地打包,保持 3:1 构图;页面隐藏、切换或场景彩蛋覆盖时暂停渲染,退出后释放绘图资源,并兼容系统减少动态效果设置。

验证:前端构建、组件类型检查、动画暂停恢复及尺寸适配检查通过;本地启动器编译通过,并确认可执行文件已包含新场景素材和页面代码。
2026-09-15 00:11:59 +08:00
Disy 0b30fc96a0 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 / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-09-14 23:06:03 +08:00
Disy 2a1dfa3c42 feat: 添加获批整合包同步与 StarLight 实例模式 2026-09-14 22:59:16 +08:00
Disy fbcaed528b feat: 完善 StarLight 皮肤站集成 2026-09-14 11:29:48 +08:00
AxTps 9dd2b013af merge: 合并远端 main 分支 2026-09-13 22:16:12 +08:00
AxTps c50595b252 fix:正版登陆的文字显示问题 2026-09-13 22:16:07 +08:00
Disy 3471cd3da2 feat: 嵌入 StarLight 皮肤站并同步登录状态 2026-09-13 21:34:09 +08:00
Disy 1593ac7a7c refactor: 移除遥测并修复启动器流程 2026-09-13 20:32:57 +08:00
Disy 1fa56add19 feat: 添加星光矿域彩蛋 2026-09-13 20:32:57 +08:00
556 changed files with 61064 additions and 47302 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 # Reproducible public-service defaults for downstream desktop builds. Private
# Modrinth services remain disabled by Axolotl capabilities at runtime. # Modrinth services remain disabled by Axolotl capabilities at runtime.
[env] [env]
@@ -12,5 +5,12 @@ MODRINTH_API_BASE_URL = "https://api.modrinth.com"
MODRINTH_API_URL = "https://api.modrinth.com/v2/" MODRINTH_API_URL = "https://api.modrinth.com/v2/"
MODRINTH_API_URL_V3 = "https://api.modrinth.com/v3/" MODRINTH_API_URL_V3 = "https://api.modrinth.com/v3/"
MODRINTH_LAUNCHER_META_URL = "https://launcher-meta.modrinth.com/" MODRINTH_LAUNCHER_META_URL = "https://launcher-meta.modrinth.com/"
MODRINTH_URL = "https://modrinth.com/"
MODRINTH_SOCKET_URL = "wss://disabled.invalid/" 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"
@@ -1,4 +1,4 @@
name: Axolotl release name: Starlight release
on: on:
push: push:
@@ -53,6 +53,15 @@ jobs:
node scripts/axolotl/i18n-check.mjs node scripts/axolotl/i18n-check.mjs
cargo fmt --all --check cargo fmt --all --check
- name: Check updater signing configuration
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]; then
echo '::error::Configure TAURI_SIGNING_PRIVATE_KEY with the private key matching apps/app/tauri-release.conf.json before publishing updates.'
exit 1
fi
- name: Prepare draft release - name: Prepare draft release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -72,7 +81,7 @@ jobs:
release_args=( release_args=(
--draft --draft
--notes-file "$release_notes" --notes-file "$release_notes"
--title "Axolotl Launcher ${GITHUB_REF_NAME}" --title "Starlight Launcher ${GITHUB_REF_NAME}"
) )
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
release_args+=(--prerelease) release_args+=(--prerelease)
@@ -175,7 +184,7 @@ jobs:
tauriScript: pnpm tauri tauriScript: pnpm tauri
args: ${{ matrix.args }} args: ${{ matrix.args }}
tagName: ${{ github.ref_name }} tagName: ${{ github.ref_name }}
releaseName: Axolotl Launcher ${{ github.ref_name }} releaseName: Starlight Launcher ${{ github.ref_name }}
releaseDraft: true releaseDraft: true
prerelease: ${{ contains(github.ref_name, '-') }} prerelease: ${{ contains(github.ref_name, '-') }}
generateReleaseNotes: false generateReleaseNotes: false
@@ -198,7 +207,7 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
$version = "${{ github.ref_name }}" -replace '^v', '' $version = "${{ github.ref_name }}" -replace '^v', ''
$appName = "Axolotl Launcher" $appName = (Get-Content apps/app/tauri.conf.json -Raw | ConvertFrom-Json).mainBinaryName
$outDir = "target/release/bundle/nsis" $outDir = "target/release/bundle/nsis"
$buildDir = "target/portable-build" $buildDir = "target/portable-build"
$publishDir = Join-Path $env:RUNNER_TEMP "windows-assets" $publishDir = Join-Path $env:RUNNER_TEMP "windows-assets"
@@ -249,6 +258,15 @@ jobs:
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null
Copy-Item -Force $appExe "$buildDir\Axolotl\" Copy-Item -Force $appExe "$buildDir\Axolotl\"
$editorResources = "target/release/resources/blockbench-skin"
foreach ($requiredFile in @('index.html', 'css/setup.css', 'dist/skin.bundle.js.gz')) {
if (!(Test-Path -LiteralPath (Join-Path $editorResources $requiredFile) -PathType Leaf)) {
throw "Missing skin editor resource: $requiredFile"
}
}
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\resources" | Out-Null
Copy-Item -LiteralPath $editorResources -Destination "$buildDir\Axolotl\resources" -Recurse -Force
# Create empty .Axolotl folder to trigger portable mode # Create empty .Axolotl folder to trigger portable mode
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null
@@ -268,18 +286,37 @@ jobs:
name: Verify and publish name: Verify and publish
needs: build needs: build
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
env:
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
steps: steps:
- name: Check out source - name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main ref: ${{ github.ref }}
- name: Download updater signatures - name: Download updater signatures
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release download "${GITHUB_REF_NAME}" --pattern '*.sig' --dir release-check/signatures 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 - name: Download release metadata
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -306,31 +343,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${GITHUB_REF_NAME}" release-check/latest.json --clobber 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 - name: Build Update Server artifact catalog
run: | run: |
node scripts/axolotl/create-update-server-catalog.mjs \ node scripts/axolotl/create-update-server-catalog.mjs \
@@ -339,7 +351,31 @@ jobs:
release-check/update-server-catalog.json release-check/update-server-catalog.json
node scripts/axolotl/create-update-server-catalog.test.mjs 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 - name: Notify Update Server to publish release assets
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
env: env:
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }} UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }} UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
@@ -350,6 +386,13 @@ jobs:
release-check/release.json \ release-check/release.json \
"${GITHUB_REF_NAME}" "${GITHUB_REF_NAME}"
- name: Check out website branch
if: ${{ !contains(github.ref_name, '-') }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: main
clean: false
- name: Publish website download metadata - name: Publish website download metadata
if: ${{ !contains(github.ref_name, '-') }} if: ${{ !contains(github.ref_name, '-') }}
env: env:
@@ -372,11 +415,14 @@ jobs:
fi fi
- name: Mirror published release to CNB - name: Mirror published release to CNB
if: ${{ env.CNB_TOKEN != '' && env.CNB_REPO_URL_HTTPS != '' }}
env: env:
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }} CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }} CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
CNB_TOKEN_USER_NAME: ${{ secrets.CNB_TOKEN_USER_NAME }} CNB_TOKEN_USER_NAME: ${{ secrets.CNB_TOKEN_USER_NAME }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_API_BASE_URL: https://api.github.com/repos/${{ github.repository }}
GITHUB_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -z "${CNB_TOKEN:-}" ]]; then if [[ -z "${CNB_TOKEN:-}" ]]; then
+5
View File
@@ -19,6 +19,11 @@ jobs:
name: Post changelog comment name: Post changelog comment
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
contents: read
steps: steps:
- name: Post or update changelog comment - name: Post or update changelog comment
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
+2 -5
View File
@@ -22,8 +22,5 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9 - run: npx --yes tombi@1.6.1 lint
with: - run: npx --yes tombi@1.6.1 fmt --check
tool: tombi@1.1.2
- run: tombi lint
- run: tombi fmt --check
+3 -2
View File
@@ -19,6 +19,7 @@ concurrency:
jobs: jobs:
sync: sync:
name: Sync Git ref name: Sync Git ref
if: ${{ vars.ENABLE_CNB_SYNC == 'true' }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
@@ -38,8 +39,8 @@ jobs:
DELETED_REF_TYPE: ${{ github.event.ref_type }} DELETED_REF_TYPE: ${{ github.event.ref_type }}
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -z "${CNB_TOKEN:-}" ]]; then if [[ -z "${CNB_TOKEN:-}" || -z "${CNB_REPO_URL_HTTPS:-}" ]]; then
echo "CNB_TOKEN is not configured" echo "CNB_TOKEN and CNB_REPO_URL_HTTPS must be configured"
exit 1 exit 1
fi fi
+2
View File
@@ -38,6 +38,7 @@ node_modules
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
testem.log testem.log
replay_pid*.log
/typings /typings
# System Files # System Files
@@ -79,6 +80,7 @@ access-jwt*.txt
# Codex local verification artifacts # Codex local verification artifacts
/.codex-* /.codex-*
/.tmp/
# Oh My Code # Oh My Code
.omc/ .omc/
Generated
+63 -40
View File
@@ -106,7 +106,7 @@ dependencies = [
"serde_json", "serde_json",
"thiserror 2.0.19", "thiserror 2.0.19",
"utoipa", "utoipa",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@@ -707,7 +707,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"fnv", "fnv",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@@ -1431,21 +1431,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "discord-rich-presence"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"thiserror 2.0.19",
"uuid 0.8.2",
]
[[package]] [[package]]
name = "dispatch2" name = "dispatch2"
version = "0.3.1" version = "0.3.1"
@@ -1904,6 +1889,28 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" 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]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -3492,6 +3499,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "memoffset" name = "memoffset"
version = "0.9.1" version = "0.9.1"
@@ -3570,7 +3586,7 @@ dependencies = [
"portable-atomic", "portable-atomic",
"smallvec", "smallvec",
"tagptr", "tagptr",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@@ -5117,6 +5133,12 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -5300,7 +5322,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"url", "url",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@@ -5773,6 +5795,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 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]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.2" version = "1.15.2"
@@ -5904,7 +5935,7 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tracing", "tracing",
"url", "url",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@@ -5984,7 +6015,7 @@ dependencies = [
"stringprep", "stringprep",
"thiserror 2.0.19", "thiserror 2.0.19",
"tracing", "tracing",
"uuid 1.24.0", "uuid",
"whoami", "whoami",
] ]
@@ -6022,7 +6053,7 @@ dependencies = [
"stringprep", "stringprep",
"thiserror 2.0.19", "thiserror 2.0.19",
"tracing", "tracing",
"uuid 1.24.0", "uuid",
"whoami", "whoami",
] ]
@@ -6048,7 +6079,7 @@ dependencies = [
"thiserror 2.0.19", "thiserror 2.0.19",
"tracing", "tracing",
"url", "url",
"uuid 1.24.0", "uuid",
] ]
[[package]] [[package]]
@@ -6454,7 +6485,7 @@ dependencies = [
"thiserror 2.0.19", "thiserror 2.0.19",
"time", "time",
"url", "url",
"uuid 1.24.0", "uuid",
"walkdir", "walkdir",
] ]
@@ -6762,7 +6793,7 @@ dependencies = [
"toml 1.1.3+spec-1.1.0", "toml 1.1.3+spec-1.1.0",
"url", "url",
"urlpattern", "urlpattern",
"uuid 1.24.0", "uuid",
"walkdir", "walkdir",
] ]
@@ -6810,7 +6841,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "1.9.6-beta.3" version = "1.0.0-beta15"
dependencies = [ dependencies = [
"ariadne", "ariadne",
"async-minecraft-ping", "async-minecraft-ping",
@@ -6833,7 +6864,6 @@ dependencies = [
"data-url", "data-url",
"derive_more", "derive_more",
"dirs", "dirs",
"discord-rich-presence",
"dotenvy", "dotenvy",
"dunce", "dunce",
"either", "either",
@@ -6901,7 +6931,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"urlencoding", "urlencoding",
"uuid 1.24.0", "uuid",
"webpki-roots", "webpki-roots",
"whoami", "whoami",
"windows 0.61.3", "windows 0.61.3",
@@ -6915,7 +6945,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "1.9.6-beta.3" version = "1.0.0-beta15"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"async_zip", "async_zip",
@@ -6928,6 +6958,7 @@ dependencies = [
"either", "either",
"enumset", "enumset",
"flate2", "flate2",
"fontdb",
"futures", "futures",
"hyper", "hyper",
"hyper-util", "hyper-util",
@@ -6942,6 +6973,7 @@ dependencies = [
"path-util", "path-util",
"quartz_nbt", "quartz_nbt",
"regex", "regex",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
@@ -6967,7 +6999,7 @@ dependencies = [
"trash", "trash",
"url", "url",
"urlencoding", "urlencoding",
"uuid 1.24.0", "uuid",
"windows 0.61.3", "windows 0.61.3",
"zip 6.0.0", "zip 6.0.0",
] ]
@@ -7691,15 +7723,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.24.0" version = "1.24.0"
@@ -8868,7 +8891,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"uds_windows", "uds_windows",
"uuid 1.24.0", "uuid",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winnow 1.0.4", "winnow 1.0.4",
"zbus_macros", "zbus_macros",
+8 -25
View File
@@ -20,9 +20,7 @@ ariadne = { path = "packages/ariadne" }
async-minecraft-ping = { path = "packages/async-minecraft-ping" } async-minecraft-ping = { path = "packages/async-minecraft-ping" }
async-recursion = "1.1.1" async-recursion = "1.1.1"
async-trait = "0.1.89" async-trait = "0.1.89"
async-tungstenite = { version = "0.31.0", default-features = false, features = [ async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
"futures-03-sink"
] }
async-walkdir = "2.1.0" async-walkdir = "2.1.0"
async_zip = "0.0.18" async_zip = "0.0.18"
base64 = "0.22.1" base64 = "0.22.1"
@@ -32,16 +30,13 @@ bytes = "1.10.1"
cc = "1.2.56" cc = "1.2.56"
chardetng = "0.1.17" chardetng = "0.1.17"
chrono = "0.4.42" chrono = "0.4.42"
cidre = { version = "0.15.0", default-features = false, features = [ cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
"macos_15_0"
] }
crc32fast = "1.5.0" crc32fast = "1.5.0"
daedalus = { path = "packages/daedalus" } daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0" dashmap = "6.1.0"
data-url = "0.3.2" data-url = "0.3.2"
derive_more = "2.1.1" derive_more = "2.1.1"
dirs = "6.0.0" dirs = "6.0.0"
discord-rich-presence = "1.0.0"
dotenvy = "0.15.7" dotenvy = "0.15.7"
dunce = "1.0.5" dunce = "1.0.5"
either = "1.15.0" either = "1.15.0"
@@ -49,6 +44,7 @@ encoding_rs = "0.8.35"
enumset = "1.1.10" enumset = "1.1.10"
eyre = "0.6.12" eyre = "0.6.12"
flate2 = "1.1.4" flate2 = "1.1.4"
fontdb = "0.24.0"
fs4 = { version = "0.13.1", default-features = false } fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31" futures = "0.3.31"
futures-lite = "2.6.1" futures-lite = "2.6.1"
@@ -59,11 +55,7 @@ hmac = "0.12.1"
http = "1.3.1" http = "1.3.1"
hyper = "1.7.0" hyper = "1.7.0"
hyper-util = "0.1.17" hyper-util = "0.1.17"
image = { version = "0.25.5", default-features = false, features = [ image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "webp"] }
"jpeg",
"png",
"webp"
] }
indicatif = "0.18.0" indicatif = "0.18.0"
itertools = "0.14.0" itertools = "0.14.0"
jsonwebtoken = "9.3.1" jsonwebtoken = "9.3.1"
@@ -89,13 +81,10 @@ rand = "=0.8.5" # Locked on 0.8 until p256 updates to 0.9
regex = "1.12.2" regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false } reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52" rgb = "0.8.52"
rustls = { version = "0.23.42", default-features = false, features = [ rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
"ring",
"std",
"tls12"
] }
rustls-native-certs = "0.8.4" rustls-native-certs = "0.8.4"
rustls-pki-types = "1.15.0" rustls-pki-types = "1.15.0"
semver = "1.0.28"
serde = "1.0.228" serde = "1.0.228"
serde_bytes = "0.11.19" serde_bytes = "0.11.19"
serde_cbor = "0.11.2" serde_cbor = "0.11.2"
@@ -128,10 +117,7 @@ tempfile = "3.23.0"
theseus = { path = "packages/app-lib" } theseus = { path = "packages/app-lib" }
thiserror = "2.0.17" thiserror = "2.0.17"
tokio = "1.47.1" tokio = "1.47.1"
tokio-rustls = { version = "0.26.4", default-features = false, features = [ tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
"ring",
"tls12"
] }
tokio-util = "0.7.16" tokio-util = "0.7.16"
toml = "0.8.19" toml = "0.8.19"
tracing = "0.1.41" tracing = "0.1.41"
@@ -150,10 +136,7 @@ windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
winreg = "0.55.0" winreg = "0.55.0"
xz2 = { version = "0.1.7", features = ["static"] } xz2 = { version = "0.1.7", features = ["static"] }
zbus = "5.11.0" zbus = "5.11.0"
zhconv = { version = "0.4.1", default-features = false, features = [ zhconv = { version = "0.4.1", default-features = false, features = ["compress", "opencc"] }
"compress",
"opencc"
] }
zip = { version = "6.0.0", default-features = false, features = [ zip = { version = "6.0.0", default-features = false, features = [
"bzip2", "bzip2",
"deflate", "deflate",
+6 -6
View File
@@ -5,13 +5,13 @@
<p><strong>次世代 Minecraft 桌面客户端,全能、美观、全平台覆盖。</strong></p> <p><strong>次世代 Minecraft 桌面客户端,全能、美观、全平台覆盖。</strong></p>
<p> <p>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/actions"> <a href="https://github.com/ApTx2012/Starlight_Lancher/actions">
<img src="https://img.shields.io/badge/Repo-git.starlight.cool-blue?style=for-the-badge&logo=git" alt="Repository" /> <img src="https://img.shields.io/badge/Repo-GitHub-black?style=for-the-badge&logo=github" alt="Repository" />
</a> </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" /> <img src="https://img.shields.io/badge/Releases-Download-green?style=for-the-badge&logo=github" alt="Releases" />
</a> </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" /> <img src="https://img.shields.io/badge/Stars-★-ffb800?style=for-the-badge&logo=github" alt="Stars" />
</a> </a>
<a href="COPYING.md"> <a href="COPYING.md">
@@ -21,7 +21,7 @@
<p> <p>
<a href="https://skin.starlight.cool/">官方网站</a> | <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="CONTRIBUTING.md">参与贡献</a>
| <a href="CODE_OF_CONDUCT.md">行为准则</a> | <a href="CODE_OF_CONDUCT.md">行为准则</a>
</p> </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 签名校验机制,自动在后台完成更新,无需手动下载安装更新。 已安装的用户每次均可通过内置的 Tauri 签名校验机制,自动在后台完成更新,无需手动下载安装更新。
| 系统平台 | 推荐下载文件 | | 系统平台 | 推荐下载文件 |
+2
View File
@@ -14,6 +14,8 @@ extend-exclude = [
"packages/app-lib/src/api/content_search/WikiEntries.txt", "packages/app-lib/src/api/content_search/WikiEntries.txt",
# Bundled Monaco editor runtime contains upstream generated assets. # Bundled Monaco editor runtime contains upstream generated assets.
"apps/app-frontend/public/monaco/", "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] [default.extend-words]
+4
View File
@@ -22,6 +22,10 @@ Item identifiers, readable names, and textures are sourced from the `minecraft-t
The recipe data and item artwork ultimately derive from Minecraft data generator output and Minecraft game resources. Minecraft and its original resources are Copyright Mojang Studios / Microsoft and are used only to identify compatible game content. Axolotl Launcher is not affiliated with or endorsed by Mojang Studios or Microsoft. The recipe data and item artwork ultimately derive from Minecraft data generator output and Minecraft game resources. Minecraft and its original resources are Copyright Mojang Studios / Microsoft and are used only to identify compatible game content. Axolotl Launcher is not affiliated with or endorsed by Mojang Studios or Microsoft.
## About-page scene attribution
The about-page victory illustration is the user-approved generated artwork. Its bundled scene layers preserve the approved preview without regenerating the illustration at runtime. The supplied netherite sword artwork and the unmodified `clouds.png` and `moon_phases.png` textures from Minecraft: Java Edition 1.20.6 are Minecraft resources, Copyright Mojang Studios / Microsoft. These assets are stored in `src/assets/about-scene` and do not require a network connection.
## AI integration attribution ## AI integration attribution
The AI provider settings information architecture, provider catalog, and provider descriptions are adapted from [LobeChat](https://github.com/lobehub/lobe-chat) at commit `a27dfaeda1ab499ac024a6eb0448917b216ba8a1`. The localized provider descriptions under `src/data/lobehub-provider-descriptions` are reproduced from that release. Bundled text-model metadata is synchronized separately from [LobeHub's model bank](https://github.com/lobehub/lobehub/tree/main/packages/model-bank/src/aiModels), with its exact source revision recorded in the backend catalog. LobeChat is distributed under the LobeHub Community License; a verbatim copy of that license is provided in [third-party/licenses/LobeHub-Community-License.txt](../../third-party/licenses/LobeHub-Community-License.txt). The AI provider settings information architecture, provider catalog, and provider descriptions are adapted from [LobeChat](https://github.com/lobehub/lobe-chat) at commit `a27dfaeda1ab499ac024a6eb0448917b216ba8a1`. The localized provider descriptions under `src/data/lobehub-provider-descriptions` are reproduced from that release. Bundled text-model metadata is synchronized separately from [LobeHub's model bank](https://github.com/lobehub/lobehub/tree/main/packages/model-bank/src/aiModels), with its exact source revision recorded in the backend catalog. LobeChat is distributed under the LobeHub Community License; a verbatim copy of that license is provided in [third-party/licenses/LobeHub-Community-License.txt](../../third-party/licenses/LobeHub-Community-License.txt).
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.0-beta1", "version": "1.0.0-beta15",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -14,6 +14,9 @@
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\"" "test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
}, },
"dependencies": { "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", "@lobehub/icons-static-svg": "1.94.0",
"@modrinth/api-client": "workspace:^", "@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
+115 -111
View File
@@ -3,12 +3,12 @@ import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrin
import { import {
ChangeSkinIcon, ChangeSkinIcon,
CompassIcon, CompassIcon,
GlobeIcon,
DownloadIcon, DownloadIcon,
ExternalIcon, ExternalIcon,
FlaskConicalIcon, FlaskConicalIcon,
FolderOpenIcon, FolderOpenIcon,
HomeIcon, HomeIcon,
ImagesIcon,
LeftArrowIcon, LeftArrowIcon,
LibraryIcon, LibraryIcon,
LogInIcon, LogInIcon,
@@ -20,6 +20,7 @@ import {
SettingsIcon, SettingsIcon,
SpinnerIcon, SpinnerIcon,
UserIcon, UserIcon,
UsersIcon,
WorldIcon, WorldIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
@@ -57,18 +58,21 @@ import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMe
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { convertFileSrc, invoke } from '@tauri-apps/api/core' import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { hideAllPoppers } from 'floating-vue'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { Effect, getCurrentWindow } from '@tauri-apps/api/window' import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { type as getOsType } from '@tauri-apps/plugin-os' import { type as getOsType } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { hideAllPoppers } from 'floating-vue'
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router' 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 InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
import AccountsCard from '@/components/ui/AccountsCard.vue' import AccountsCard from '@/components/ui/AccountsCard.vue'
import AnniversaryCelebration from '@/components/ui/AnniversaryCelebration.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue' import AppActionBar from '@/components/ui/AppActionBar.vue'
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue' import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue' import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
@@ -85,12 +89,13 @@ import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerMod
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue' import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue' import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue' import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue'
import PrivacyConsentModal from '@/components/ui/modal/PrivacyConsentModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue' import NavButton from '@/components/ui/NavButton.vue'
import NavRail from '@/components/ui/NavRail.vue' import NavRail from '@/components/ui/NavRail.vue'
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue' import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue' import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue' import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js' import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
@@ -98,7 +103,6 @@ import { useDropImport } from '@/composables/useDropImport'
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError' import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config' import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
import { trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js' import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js' import { get_user, get_version } from '@/helpers/cache.js'
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge' import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
@@ -113,22 +117,20 @@ import {
} from '@/helpers/events.js' } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance' import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance'
import { PlayerSelectionNavigatedAwayError } from '@/helpers/instance-player'
import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth' import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts' import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { import {
get as getSettings, get as getSettings,
getLastBrowseContentProjectType, getLastBrowseContentProjectType,
getPrivacySettings,
getUpdateChannel, getUpdateChannel,
getUpdatePreferences, getUpdatePreferences,
isBrowseContentProjectType, isBrowseContentProjectType,
type PrivacySettings,
savePrivacySettings,
set as setSettings, set as setSettings,
} from '@/helpers/settings.ts' } from '@/helpers/settings.ts'
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts' import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
import { get_opening_command, initialize_state, set_discord_activity } from '@/helpers/state' import { get_opening_command, initialize_state } from '@/helpers/state'
import { import {
areUpdatesEnabled, areUpdatesEnabled,
backupAppDbForUpdate, backupAppDbForUpdate,
@@ -140,9 +142,9 @@ import {
isDev, isDev,
isElevated, isElevated,
isNetworkMetered, isNetworkMetered,
restartApp,
setRestartAfterPendingUpdate, setRestartAfterPendingUpdate,
} from '@/helpers/utils.js' } from '@/helpers/utils.js'
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts' import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n, { resolveInitialLocale } from '@/i18n.config' import i18n, { resolveInitialLocale } from '@/i18n.config'
import { import {
@@ -314,6 +316,22 @@ const showOnboarding = ref(false)
const onboardingMode = ref('main') const onboardingMode = ref('main')
const onboardingSettings = ref(null) const onboardingSettings = ref(null)
const onboardingReplay = ref(false) 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 nativeDecorations = ref(false)
const os = ref('') const os = ref('')
@@ -369,8 +387,6 @@ watch(
) )
const stateInitialized = ref(false) const stateInitialized = ref(false)
const privacyConsentModal = ref<InstanceType<typeof PrivacyConsentModal>>()
const privacyConsentPending = ref(false)
const closeChoiceModal = ref<InstanceType<typeof NewModal>>() const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
const closeChoiceOpen = ref(false) const closeChoiceOpen = ref(false)
const closeChoiceRemember = ref(false) const closeChoiceRemember = ref(false)
@@ -494,9 +510,16 @@ onMounted(async () => {
document.querySelector('body').addEventListener('auxclick', handleAuxClick) document.querySelector('body').addEventListener('auxclick', handleAuxClick)
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, handleDirectLinkSyncReport) window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, handleDirectLinkSyncReport)
try {
await stateInitialization
} catch {
return
}
checkUpdates() checkUpdates()
void warnIfRunningElevated() void warnIfRunningElevated()
startDirectLinkSync() startDirectLinkSync()
maybeShowStartupAnnouncement()
}) })
let directLinkSync: (() => Promise<void>) | undefined let directLinkSync: (() => Promise<void>) | undefined
@@ -737,10 +760,6 @@ const messages = defineMessages({
id: 'app.navigation.skin-selector', id: 'app.navigation.skin-selector',
defaultMessage: 'Skin selector', defaultMessage: 'Skin selector',
}, },
starlightSkin: {
id: 'app.navigation.starlight-skin',
defaultMessage: '斯达莱特',
},
library: { library: {
id: 'app.navigation.library', id: 'app.navigation.library',
defaultMessage: 'Library', defaultMessage: 'Library',
@@ -749,6 +768,10 @@ const messages = defineMessages({
id: 'app.navigation.multiplayer', id: 'app.navigation.multiplayer',
defaultMessage: 'Multiplayer', defaultMessage: 'Multiplayer',
}, },
screenshots: {
id: 'app.navigation.screenshots',
defaultMessage: 'Screenshots',
},
downloads: { downloads: {
id: 'app.navigation.downloads', id: 'app.navigation.downloads',
defaultMessage: 'Downloads', defaultMessage: 'Downloads',
@@ -765,9 +788,9 @@ const messages = defineMessages({
id: 'app.account.signed-in-as', id: 'app.account.signed-in-as',
defaultMessage: 'Signed in as', defaultMessage: 'Signed in as',
}, },
playingAs: { userInformation: {
id: 'app.minecraft.playing-as', id: 'app.minecraft.user-information',
defaultMessage: 'Playing as', defaultMessage: 'User information',
}, },
collapseSidebar: { collapseSidebar: {
id: 'app.sidebar.collapse', id: 'app.sidebar.collapse',
@@ -1055,9 +1078,6 @@ async function setupApp() {
theme, theme,
accent_color, accent_color,
locale, locale,
telemetry,
telemetry_consent_version,
discord_rpc,
collapsed_navigation, collapsed_navigation,
hide_nametag_skins_page, hide_nametag_skins_page,
advanced_rendering, advanced_rendering,
@@ -1077,6 +1097,7 @@ async function setupApp() {
close_behavior, close_behavior,
developer_mode, developer_mode,
feature_flags, feature_flags,
ui_scale,
} = initialSettings } = initialSettings
// Initialize locale from saved settings // Initialize locale from saved settings
@@ -1101,13 +1122,13 @@ async function setupApp() {
const dev = await isDev() const dev = await isDev()
isDevEnvironment.value = dev isDevEnvironment.value = dev
if (!onboarded && route.path !== '/') await router.replace('/') if (!onboarded && route.path !== '/') await router.replace('/')
privacyConsentPending.value = telemetry_consent_version < 1
showOnboarding.value = false showOnboarding.value = false
onboardingSettings.value = initialSettings onboardingSettings.value = initialSettings
nativeDecorations.value = native_decorations nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations) if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
setUiScale(ui_scale)
themeStore.setThemeState(theme) themeStore.setThemeState(theme)
await initializeSystemAccentColor() await initializeSystemAccentColor()
themeStore.setAccentColor(accent_color) themeStore.setAccentColor(accent_color)
@@ -1132,16 +1153,7 @@ async function setupApp() {
themeStore.devMode = developer_mode themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags themeStore.featureFlags = feature_flags
stateInitialized.value = true stateInitialized.value = true
if (privacyConsentPending.value) { showOnboarding.value = !onboarded
await nextTick()
privacyConsentModal.value?.show({
telemetry,
discord_rpc,
consent_version: telemetry_consent_version,
})
} else {
showOnboarding.value = !onboarded
}
void reconcileMojangAuthSourceAtStartup().catch(handleError) void reconcileMojangAuthSourceAtStartup().catch(handleError)
isMaximized.value = await getCurrentWindow().isMaximized() isMaximized.value = await getCurrentWindow().isMaximized()
@@ -1249,7 +1261,7 @@ async function replayOnboarding(mode) {
async function finishOnboarding() { async function finishOnboarding() {
const wasReplay = onboardingReplay.value const wasReplay = onboardingReplay.value
const settings = onboardingSettings.value ?? (await getSettings()) const settings = await getSettings()
if (!onboardingReplay.value) { if (!onboardingReplay.value) {
if (onboardingMode.value === 'instance') { if (onboardingMode.value === 'instance') {
settings.onboarding_instance_tour_completed = true settings.onboarding_instance_tour_completed = true
@@ -1274,42 +1286,7 @@ async function closeOnboardingSettings() {
} }
async function scheduleStartupDialogs() { async function scheduleStartupDialogs() {
if (!stateInitialized.value || privacyConsentPending.value || showOnboarding.value) return if (!stateInitialized.value || showOnboarding.value) return
}
async function handlePrivacyConsentSaved(privacy: PrivacySettings) {
privacyConsentPending.value = false
if (onboardingSettings.value) {
onboardingSettings.value.telemetry = privacy.telemetry
onboardingSettings.value.discord_rpc = privacy.discord_rpc
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
}
if (!onboardingSettings.value?.onboarded) {
startOnboarding('main')
} else {
await scheduleStartupDialogs()
}
}
async function previewPrivacyConsentModal() {
try {
const current = await getPrivacySettings()
const privacy = await savePrivacySettings({
telemetry: false,
discord_rpc: current.discord_rpc,
consent_version: 0,
})
privacyConsentPending.value = true
if (onboardingSettings.value) {
onboardingSettings.value.telemetry = privacy.telemetry
onboardingSettings.value.discord_rpc = privacy.discord_rpc
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
}
await nextTick()
privacyConsentModal.value?.show(privacy)
} catch (error) {
handleError(error)
}
} }
provide('replayOnboarding', replayOnboarding) provide('replayOnboarding', replayOnboarding)
@@ -1320,7 +1297,9 @@ provide(
) )
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview()) provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
provide('showLauncherPopup', (_request: unknown) => {}) provide('showLauncherPopup', (_request: unknown) => {})
provide('previewPrivacyConsentModal', previewPrivacyConsentModal) provide('showStartupAnnouncement', () => startupAnnouncementModal.value?.show())
installUiScale()
const stateFailed = ref(false) const stateFailed = ref(false)
stateInitialization stateInitialization
@@ -1429,9 +1408,6 @@ loading.setEnabled(false)
let initialLoadToken = loading.begin() let initialLoadToken = loading.begin()
let routerToken = null let routerToken = null
let suspenseToken = null let suspenseToken = null
let lastDiscordActivity = null
let discordActivityUpdate = Promise.resolve()
let suspensePending = false let suspensePending = false
const sidebarOverlayScrollbarsOptions = Object.freeze({ const sidebarOverlayScrollbarsOptions = Object.freeze({
@@ -1447,31 +1423,11 @@ router.beforeEach(() => {
routerToken = loading.begin() routerToken = loading.begin()
}) })
function syncDiscordActivity(to: RouteLocationNormalizedLoaded) {
const activity =
typeof to.meta.discordActivity === 'string' ? to.meta.discordActivity : 'Idling...'
if (activity === lastDiscordActivity) return
lastDiscordActivity = activity
discordActivityUpdate = discordActivityUpdate
.then(() => set_discord_activity(activity))
.catch((error) => {
if (lastDiscordActivity === activity) lastDiscordActivity = null
console.error('Failed to update Discord activity', error)
})
}
router.afterEach((to, from, failure) => { router.afterEach((to, from, failure) => {
hideAllPoppers() hideAllPoppers()
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath }) if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
failed: failure,
})
if (!failure) { if (!failure) {
void directLinkSync?.() void directLinkSync?.()
if (stateInitialized.value) syncDiscordActivity(to)
} }
setTimeout(() => { setTimeout(() => {
if (!suspensePending && stateInitialized.value) { if (!suspensePending && stateInitialized.value) {
@@ -1508,7 +1464,6 @@ watch(
stateInitialized, stateInitialized,
(ready) => { (ready) => {
if (ready) { if (ready) {
syncDiscordActivity(router.currentRoute.value)
if (initialLoadToken) { if (initialLoadToken) {
loading.end(initialLoadToken) loading.end(initialLoadToken)
initialLoadToken = null initialLoadToken = null
@@ -1614,6 +1569,33 @@ const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = 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 ────────────────────────────────────────────────── // ── Drop import system ──────────────────────────────────────────────────
const dropImport = useDropImport({ const dropImport = useDropImport({
notificationManager, notificationManager,
@@ -1624,7 +1606,6 @@ const dropImport = useDropImport({
onSkinsPage, onSkinsPage,
onSchematicWorkshopPage, onSchematicWorkshopPage,
isSchematicFile, isSchematicFile,
trackEvent,
router, router,
}) })
@@ -1783,6 +1764,7 @@ onMounted(() => {
setServerUpdateToPlayModal(updateToPlayModal.value) setServerUpdateToPlayModal(updateToPlayModal.value)
void (async () => { void (async () => {
try { try {
await stateInitialization
const ready = await invoke<{ const ready = await invoke<{
pending_crashes: { instance_id: string; uuid: string }[] pending_crashes: { instance_id: string; uuid: string }[]
pending_commands: Parameters<typeof handleCommand>[0][] pending_commands: Parameters<typeof handleCommand>[0][]
@@ -1836,13 +1818,14 @@ async function handleCommand(e) {
} else { } else {
await install_create_modpack_instance(location).catch(handleError) await install_create_modpack_instance(location).catch(handleError)
} }
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
} }
} else if (e.event === 'LaunchInstance') { } else if (e.event === 'LaunchInstance') {
const instance = await getInstance(e.id).catch(() => null) const instance = await getInstance(e.id).catch(() => null)
const handleLaunchCommandError = async (launchError) => { const handleLaunchCommandError = async (launchError) => {
// Navigating to the skin-site login to pick a player is a deliberate
// user action, not a launch failure: stay silent and let the user
// re-trigger the launch after signing in.
if (launchError instanceof PlayerSelectionNavigatedAwayError) return
const handled = const handled =
(await minecraftCrashModal.value?.handleLaunchError(launchError, { (await minecraftCrashModal.value?.handleLaunchError(launchError, {
instance_id: e.id, instance_id: e.id,
@@ -2239,7 +2222,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</script> </script>
<template> <template>
<AnniversaryCelebration />
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region /> <SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<SkinSiteSessionFrame v-if="stateInitialized" />
<InstancePlayerModal v-if="stateInitialized" />
<StartupAnnouncementModal ref="startupAnnouncementModal" />
<div id="teleports"></div> <div id="teleports"></div>
<div <div
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground" v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
@@ -2321,13 +2308,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
<ChangeSkinIcon /> <ChangeSkinIcon />
</NavButton> </NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.starlightSkin)"
data-onboarding-id="nav-starlight-skin"
to="/starlight-skin"
>
<GlobeIcon />
</NavButton>
<NavButton <NavButton
v-tooltip.right="formatMessage(messages.library)" v-tooltip.right="formatMessage(messages.library)"
data-onboarding-id="nav-library" data-onboarding-id="nav-library"
@@ -2342,6 +2322,23 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
<LibraryIcon /> <LibraryIcon />
</NavButton> </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 <NavButton
v-tooltip.right="formatMessage(messages.lab)" v-tooltip.right="formatMessage(messages.lab)"
data-onboarding-id="nav-lab" data-onboarding-id="nav-lab"
@@ -2509,9 +2506,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
{{ formatMessage(messages.authUnreachableBody) }} {{ formatMessage(messages.authUnreachableBody) }}
</Admonition> </Admonition>
<div class="page-transition-grid grid min-h-full"> <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')"> <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"> <Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<component :is="Component"></component> <component :is="Component"></component>
</Suspense> </Suspense>
@@ -2551,9 +2552,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div> <div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }"> <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"> <h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }} {{ formatMessage(messages.userInformation) }}
</h3> </h3>
<suspense> <suspense>
<AccountsCard ref="accounts" /> <AccountsCard ref="accounts" />
@@ -2575,9 +2579,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:on-error-action="exportNotificationErrorLogs" :on-error-action="exportNotificationErrorLogs"
:error-action-label="formatMessage(messages.exportErrorLogs)" :error-action-label="formatMessage(messages.exportErrorLogs)"
/> />
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" /> <MinecraftCrashModal v-if="stateInitialized" ref="minecraftCrashModal" @error="handleError" />
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" /> <JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
<PrivacyConsentModal ref="privacyConsentModal" @saved="handlePrivacyConsentSaved" />
<NewModal <NewModal
ref="closeChoiceModal" ref="closeChoiceModal"
:header="formatMessage(messages.closeLauncherTitle)" :header="formatMessage(messages.closeLauncherTitle)"
@@ -2664,6 +2667,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@install="handleContentInstallModpackInstall" @install="handleContentInstallModpackInstall"
@cancel="handleContentInstallModpackInstallCancel" @cancel="handleContentInstallModpackInstallCancel"
/> />
<TaggedModDownloadsModal v-if="stateInitialized" />
<CurseForgeManualDownloadsModal <CurseForgeManualDownloadsModal
ref="contentInstallCurseForgeManualDownloadsModal" ref="contentInstallCurseForgeManualDownloadsModal"
@view-instance="handleContentInstallModpackDuplicateGoToInstance" @view-instance="handleContentInstallModpackDuplicateGoToInstance"
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,31 @@
# 凋灵局部清理素材
## 横梁删除修正(2026-09-15)
- 素材:[wither-beams-removed.png](./wither-beams-removed.png),2172 × 724。
- 方式:内置 imagegen,局部对象删除;输入为上一版清理图与用户标注的两处横梁。
- 仅使用左右横梁及其侧面的轮廓区域,补回露出的背景;其他位置仍使用之前的清晰素材。对应的横梁护盾面同时删除。
- 不重编码既有母图,不使用整张新生成图替换画面,不增加模糊或羽化。
最终提示词:
Use case: precise-object-edit.
Image 1 is the edit target: the full 3:1 Minecraft night scene. Image 2 is a location guide only, with red rectangles identifying two unwanted horizontal shoulder beams. Do NOT copy the UI, red marks, sword, star, clouds or alternate appearance from image 2.
Remove the TWO long thin horizontal brown/black bars running out sideways from the fallen Wither's upper chest to its left and right side skulls. In image 1 at normalized 2048x683 coordinates these are approximately left x978–1140 y467–527 and right x1280–1405 y487–535. ERASE these bars completely, including their top face and side face; show the existing dark blue-gray ground/background visible through the new open gaps. The left and right skulls must stay in their exact positions on the ground, separated visually from the chest in these gaps. Do not invent substitute bars, short stumps, connecting rods, upright shoulder bones, debris, ribs or decorations in these two gaps.
Preserve all other pixels/geometry/composition: the three blocky skulls, central neck, broken rib cage, spine, player's exact pose and clothes, moon, landscape, frame edges, lighting and colors. In particular do not remove or redraw the existing curved/segmented chest ribs below the two bars. Precisely restore the small exposed background behind removed bars, with crisp square voxel texture. No blur, smoothing, feathering, depth of field, re-rendering of the whole image, glow or haze. Keep the original full 3:1 frame and exact object registration, at 2172x724 if possible. This is a tiny local object removal, not a restyle.
## 上一版肩部凸出结构清理
- 素材:[wither-shoulder-cleanup.png](./wither-shoulder-cleanup.png),2172 × 724。
- 生成方式:内置 imagegen 图像编辑工具。
- 编辑目标:`victory-clean.jpg`;辅助参考为用户标红的两处肩部异常结构及 Minecraft 凋灵截图。
- 实际使用:仅将两处肩部异常结构的清理结果合成到清晰母图。整张生成图不作为背景使用,三个头、肋骨、角色和环境保留原有素材。
- 动画在独立图层中绘制;护盾几何记录在 `wither-shield.ts`,坐标以 2048 × 682⅔ 的场景空间表示。
## 生成时使用的提示词
Use case: precise-object-edit. Asset type: a sharp replacement Wither plate for an existing Minecraft cinematic animated banner. Image 1 is the EDIT TARGET: retain its exact 3:1 panoramic framing and camera, keep every other object and the character unchanged. Image 2 is ONLY an annotation reference: its red rectangles identify two erroneous upright slabs/extra structures on the Wither's shoulders which must be removed; do not draw any red marks. Image 3 is ONLY a vanilla Minecraft Wither anatomy reference; use its simple three-headed skeletal structure, not its standing pose or blue shield.
Regenerate ONLY the fallen Wither in Image 1 cleanly, with crisp straight voxel edges, dark charcoal/black nether bone material and clean low-resolution Minecraft pixel textures. Exactly three cuboid skulls: near-left skull under the player's raised boot, central skull farther back facing upward, right skull at the right, preserving their existing centers, scale, screen orientation and silhouette as closely as possible. Retain the existing supine defeated pose, clean horizontal shoulder crossbar linking the three heads, a compact rib cage with separated squared ribs, and the central spine receding into the foreground. Remove the two upright paddle-like shoulder projections indicated by the red boxes, all extra heads, all spikes/horns, all gold mechanisms, odd rubble embedded in the body, noisy invented decorations and duplicate bones. The chest has one coherent small broken sternum opening centered exactly where the animated sword will enter (about 56% across and 72% down the full panorama). Preserve visible negative space between ribs, without random detached bits. This is a defeated non-human Minecraft boss, no blood or gore.
Keep the player's existing foot resting on the near-left skull at precisely the same height and location. Keep the character, face, volumetric brown hair, yellow clothing, armor, hands and boots absolutely unchanged. Keep the sky, moon, trees, terrain and all other regions unchanged and sharp. Do NOT add a sword or nether star: these already exist as separate animated layers. Do NOT paint any blue shielding, electricity, glow halos or particles onto the Wither; those will be separate precisely aligned animated layers. Match existing cool night illumination and subtle warm gold light on the top-facing bone planes. Reconstruct only the small pieces of ground/sky revealed by removing the two wrong shoulder slabs. No blur, no painterly smoothing, no depth-of-field blur, no sharpening halos. Output a single full panoramic 3:1 edited plate, same framing as Image 1, at the highest available resolution.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<title>幸运四叶草</title>
<path
d="M33 34C39.5 38.5 44.5 46 47 56"
stroke="var(--clover-edge, #16883F)"
stroke-width="5.5"
stroke-linecap="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(90 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(180 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(270 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<circle cx="32" cy="32" r="3.7" fill="var(--clover-edge, #16883F)" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

@@ -28,7 +28,6 @@ import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install' import { install_duplicate_instance } from '@/helpers/install'
import { kill, remove, run } from '@/helpers/instance' import { kill, remove, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process.js' import { get_by_instance_id } from '@/helpers/process.js'
@@ -43,6 +42,7 @@ const { offline } = useNetworkStatus()
const handleMinecraftLaunchError = useMinecraftLaunchError() const handleMinecraftLaunchError = useMinecraftLaunchError()
const messages = defineMessages({ const messages = defineMessages({
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' }, addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' }, viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' },
duplicateInstance: { duplicateInstance: {
@@ -158,17 +158,9 @@ const handleOptionsClick = async (args) => {
}) })
if (!handled) handleSevereError(err, { instanceId: args.item.id }) if (!handled) handleSevereError(err, { instanceId: args.item.id })
}) })
trackEvent('InstanceStart', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break break
case 'stop': case 'stop':
await kill(args.item.id).catch(handleError) await kill(args.item.id).catch(handleError)
trackEvent('InstanceStop', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break break
case 'add_content': case 'add_content':
await router.push({ await router.push({
@@ -274,7 +266,12 @@ onUnmounted(() => {
@delete="deleteInstance" @delete="deleteInstance"
/> />
<div ref="rowContainer" class="flex flex-col gap-4"> <div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row flex flex-col items-start overflow-hidden w-full min-w-full"> <div
v-for="row in actualInstances"
ref="rows"
:key="row.label"
class="row flex flex-col items-start overflow-hidden w-full min-w-full"
>
<HeadingLink class="mt-1" :to="row.route"> <HeadingLink class="mt-1" :to="row.route">
{{ row.label }} {{ row.label }}
</HeadingLink> </HeadingLink>
@@ -316,7 +313,7 @@ onUnmounted(() => {
</div> </div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick"> <ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template> <template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template> <template #stop> <StopCircleIcon /> {{ formatMessage(messages.stopAll) }} </template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template> <template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template> <template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
<template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </template> <template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </template>
@@ -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>
@@ -18,7 +18,6 @@ import { computed, ref, watch } from 'vue'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime' import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { import {
type DailyPlaytime, type DailyPlaytime,
type DailyPlaytimeEntry, type DailyPlaytimeEntry,
@@ -200,11 +199,6 @@ function selectDay(dateKey: string) {
async function playInstance(instance: GameInstance) { async function playInstance(instance: GameInstance) {
try { try {
await run(instance.id) await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeCalendar',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@@ -216,11 +210,6 @@ async function playInstance(instance: GameInstance) {
async function stopInstance(instance: GameInstance) { async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeCalendar',
})
} }
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true }) watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets' import { DownloadIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui' import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { loading_listener } from '@/helpers/events' import { loading_listener } from '@/helpers/events'
import type { LoadingBar } from '@/helpers/state' import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -15,6 +15,7 @@ const messages = defineMessages({
}) })
interface LoadingEventPayload { interface LoadingEventPayload {
total?: number | null
event: LoadingBar['bar_type'] event: LoadingBar['bar_type']
loader_uuid: string loader_uuid: string
fraction: number | null fraction: number | null
@@ -22,6 +23,7 @@ interface LoadingEventPayload {
} }
interface LaunchProgressItem { interface LaunchProgressItem {
waiting: boolean
key: string key: string
message: string message: string
fraction: number | null fraction: number | null
@@ -29,6 +31,7 @@ interface LaunchProgressItem {
// 启动相关(以及准备)阶段会发的 loading 类型;安装/下载也一并显示,便于用户看到进度 // 启动相关(以及准备)阶段会发的 loading 类型;安装/下载也一并显示,便于用户看到进度
const LAUNCH_BAR_TYPES = new Set([ const LAUNCH_BAR_TYPES = new Set([
'hosted_pack_sync',
'minecraft_download', 'minecraft_download',
'instance_update', 'instance_update',
'zip_extract', 'zip_extract',
@@ -54,6 +57,7 @@ function applyEvent(payload: LoadingEventPayload) {
if (!isVisible(payload.event)) return if (!isVisible(payload.event)) return
activeMap.set(payload.loader_uuid, { activeMap.set(payload.loader_uuid, {
waiting: payload.total === 0,
key: payload.loader_uuid, key: payload.loader_uuid,
message: payload.message, message: payload.message,
fraction: payload.fraction, fraction: payload.fraction,
@@ -64,13 +68,28 @@ function applyEvent(payload: LoadingEventPayload) {
const hasProgress = computed(() => progressItems.value.length > 0) const hasProgress = computed(() => progressItems.value.length > 0)
function percent(item: LaunchProgressItem): string { function percent(item: LaunchProgressItem): string {
if (item.fraction == null || !Number.isFinite(item.fraction)) return '' if (item.waiting || item.fraction == null || !Number.isFinite(item.fraction)) return ''
return `${Math.round(Math.max(0, Math.min(1, item.fraction)) * 100)}%` return `${Math.round(Math.max(0, Math.min(1, item.fraction)) * 100)}%`
} }
let initializing = true
const buffered: LoadingEventPayload[] = []
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => { const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
applyEvent(payload) if (initializing) buffered.push(payload)
else applyEvent(payload)
}) })
const bars = await progress_bars_list().catch(() => ({}))
for (const bar of Object.values(bars)) {
applyEvent({
event: bar.bar_type,
loader_uuid: String(bar.loading_bar_uuid),
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
total: bar.total,
message: bar.message ?? '',
})
}
initializing = false
for (const payload of buffered) applyEvent(payload)
onUnmounted(() => { onUnmounted(() => {
unlistenLoading?.() unlistenLoading?.()
@@ -96,12 +115,7 @@ onUnmounted(() => {
{{ percent(item) }} {{ percent(item) }}
</span> </span>
</div> </div>
<div class="h-1 w-full overflow-hidden rounded-full bg-surface-4"> <ProgressBar :progress="item.fraction ?? 0" :waiting="item.waiting" full-width />
<div
class="h-full rounded-full bg-brand transition-[width] duration-200"
:style="{ width: percent(item) || '100%' }"
/>
</div>
</li> </li>
</ul> </ul>
</section> </section>
@@ -1,35 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets' import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui' import { computed, ref, watch } from 'vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import LuckyCloverIcon from '@/assets/icons/lucky-clover.svg'
import {
openSkinSiteLogin,
requestSkinSiteLuck,
skinSiteUser,
} from '@/composables/skin-site-session'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({ const messages = defineMessages({
widgetTitle: { id: 'app.home.skin-site.title', defaultMessage: 'StarLight skin site' }, widgetTitle: { id: 'app.home.luck.title', defaultMessage: 'Daily luck index' },
luckyIndex: { id: 'app.home.skin-site.lucky-index', defaultMessage: 'Lucky index' }, getLuck: { id: 'app.home.luck.get', defaultMessage: 'Get with one click' },
jump: { id: 'app.home.skin-site.jump', defaultMessage: 'Jump to StarLight skin site' }, loginToGet: { id: 'app.home.luck.login-to-get', defaultMessage: 'Sign in to get it' },
loading: { id: 'app.home.luck.loading', defaultMessage: 'Asking the little sprite...' },
error: {
id: 'app.home.luck.error',
defaultMessage: 'The little sprite did not answer. Please try again later.',
},
outOf: { id: 'app.home.luck.out-of', defaultMessage: 'out of 100' },
}) })
const SKIN_PROFILE_URL = 'https://skin.starlight.cool/profile' const luck = ref<number | null>(null)
const SKIN_HOME_URL = 'https://skin.starlight.cool/' const loading = ref(false)
const failed = ref(false)
const waitingForLogin = ref(false)
const signedIn = computed(() => Boolean(skinSiteUser.value))
async function openProfile() { async function fetchLuck() {
if (loading.value) return
loading.value = true
failed.value = false
try { try {
await openUrl(SKIN_PROFILE_URL) luck.value = await requestSkinSiteLuck()
} catch (error) { } catch {
handleError(error) failed.value = true
} finally {
loading.value = false
} }
} }
async function openSkinSite() { function handlePrimaryAction() {
try { if (signedIn.value) {
await openUrl(SKIN_HOME_URL) void fetchLuck()
} catch (error) { return
handleError(error)
} }
waitingForLogin.value = true
openSkinSiteLogin()
} }
watch(
() => skinSiteUser.value?.uuid,
(uuid, previousUuid) => {
if (uuid === previousUuid) return
luck.value = null
failed.value = false
if (uuid && waitingForLogin.value) {
waitingForLogin.value = false
void fetchLuck()
} else if (!uuid) {
waitingForLogin.value = false
}
},
)
</script> </script>
<template> <template>
@@ -37,32 +72,72 @@ async function openSkinSite() {
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4" class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" /> <LuckyCloverIcon class="lucky-clover size-5 shrink-0" aria-hidden="true" />
<h2 class="m-0 truncate text-lg"> <h2 class="m-0 truncate text-lg">
{{ formatMessage(messages.widgetTitle) }} {{ formatMessage(messages.widgetTitle) }}
</h2> </h2>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-3">
<button <div v-if="loading" class="luck-feedback" role="status" aria-live="polite">
type="button" <LuckyCloverIcon
class="flex w-full cursor-pointer items-center justify-between rounded-lg border border-[--brand-gradient-border] bg-transparent px-3 py-2 text-left transition-colors hover:bg-button-bg" class="lucky-clover lucky-clover-loading size-7 shrink-0"
@click="openProfile" aria-hidden="true"
> />
<span class="text-sm font-semibold text-contrast"> <span>{{ formatMessage(messages.loading) }}</span>
{{ formatMessage(messages.luckyIndex) }} </div>
</span> <div v-else-if="luck !== null" class="luck-feedback" aria-live="polite">
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" /> <strong class="text-3xl leading-none text-contrast">{{ luck }}</strong>
</button> <span class="text-xs text-secondary">{{ formatMessage(messages.outOf) }}</span>
</div>
<p v-else-if="failed" class="m-0 text-sm leading-5 text-secondary" role="alert">
{{ formatMessage(messages.error) }}
</p>
<button <ButtonStyled color="brand" class="w-full">
type="button" <button type="button" class="w-full" :disabled="loading" @click="handlePrimaryAction">
class="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-[--brand-gradient] px-3 py-2 font-semibold text-[var(--color-contrast)] transition-opacity hover:opacity-90" {{ formatMessage(signedIn ? messages.getLuck : messages.loginToGet) }}
@click="openSkinSite" </button>
> </ButtonStyled>
<ExternalIcon class="size-4 shrink-0" aria-hidden="true" />
<span>{{ formatMessage(messages.jump) }}</span>
</button>
</div> </div>
</section> </section>
</template> </template>
<style scoped>
.lucky-clover {
--clover-leaf: color-mix(in srgb, #42c95a 88%, var(--color-brand));
--clover-edge: color-mix(in srgb, #16883f 88%, var(--color-brand));
}
.luck-feedback {
display: flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 600;
color: var(--color-secondary);
text-align: center;
}
.lucky-clover-loading {
transform-origin: center;
animation: clover-wait 850ms ease-in-out infinite;
}
@keyframes clover-wait {
0%,
100% {
transform: translateY(1px) rotate(-5deg) scale(0.96);
}
50% {
transform: translateY(-2px) rotate(6deg) scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.lucky-clover-loading {
animation: none;
}
}
</style>
@@ -25,10 +25,10 @@ import HomeGreeting from '@/components/home/HomeGreeting.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install' import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error' import { handleSevereError } from '@/store/error'
@@ -51,6 +51,8 @@ const handleMinecraftLaunchError = useMinecraftLaunchError()
const { offline } = useNetworkStatus() const { offline } = useNetworkStatus()
const messages = defineMessages({ const messages = defineMessages({
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
chooseInstance: { chooseInstance: {
id: 'app.home.minimal.choose-instance', id: 'app.home.minimal.choose-instance',
defaultMessage: 'Choose instance', defaultMessage: 'Choose instance',
@@ -89,14 +91,17 @@ const selectedInstance = computed(() =>
props.instances.find((instance) => instance.id === props.selectedInstanceId), props.instances.find((instance) => instance.id === props.selectedInstanceId),
) )
const running = ref(false) const running = ref(false)
const loading = ref(false) const installingId = ref<string>()
const currentEvent = ref<string | null>(null)
const installed = computed(() => selectedInstance.value?.install_stage === 'installed') const installed = computed(() => selectedInstance.value?.install_stage === 'installed')
const installing = computed( const installing = computed(
() => selectedInstance.value?.install_stage.includes('installing') ?? false, () => selectedInstance.value?.install_stage.includes('installing') ?? false,
) )
const busy = computed( const busy = computed(
() => loading.value || installing.value || (currentEvent.value === 'launched' && !running.value), () =>
!!selectedInstance.value &&
(isInstanceLaunching(selectedInstance.value.id) ||
installingId.value === selectedInstance.value.id ||
installing.value),
) )
const lastPlayed = computed(() => { const lastPlayed = computed(() => {
@@ -112,25 +117,19 @@ async function refreshProcessState() {
return return
} }
const processes = await get_by_instance_id(selectedInstance.value.id).catch((error) => { const id = selectedInstance.value.id
const processes = await get_by_instance_id(id).catch((error) => {
handleError(error) handleError(error)
return [] return []
}) })
running.value = processes.length > 0 if (selectedInstance.value?.id === id) running.value = processes.length > 0
} }
async function playInstance() { async function playInstance() {
const instance = selectedInstance.value const instance = selectedInstance.value
if (!instance) return if (!instance || busy.value) return
loading.value = true
try { try {
await run(instance.id) await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeMinimal',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@@ -138,7 +137,6 @@ async function playInstance() {
}) })
if (!handled) handleSevereError(error, { instanceId: instance.id }) if (!handled) handleSevereError(error, { instanceId: instance.id })
} finally { } finally {
loading.value = false
await refreshProcessState() await refreshProcessState()
} }
} }
@@ -148,19 +146,14 @@ async function stopInstance() {
if (!instance) return if (!instance) return
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
running.value = false await refreshProcessState()
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeMinimal',
})
} }
async function installInstance() { async function installInstance() {
const instance = selectedInstance.value const instance = selectedInstance.value
if (!instance) return if (!instance) return
loading.value = true installingId.value = instance.id
try { try {
if ( if (
instance.install_stage !== 'pack_installed' && instance.install_stage !== 'pack_installed' &&
@@ -179,14 +172,14 @@ async function installInstance() {
} catch (error) { } catch (error) {
handleError(error) handleError(error)
} finally { } finally {
loading.value = false if (installingId.value === instance.id) installingId.value = undefined
} }
} }
watch( watch(
() => props.selectedInstanceId, () => props.selectedInstanceId,
() => { () => {
currentEvent.value = null running.value = false
void refreshProcessState() void refreshProcessState()
}, },
) )
@@ -195,9 +188,7 @@ await refreshProcessState()
const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => { const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => {
if (event.instance_id !== selectedInstance.value?.id) return if (event.instance_id !== selectedInstance.value?.id) return
currentEvent.value = event.event void refreshProcessState()
if (event.event === 'finished') running.value = false
else void refreshProcessState()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -210,7 +201,7 @@ onUnmounted(() => {
data-onboarding-id="home-instances" data-onboarding-id="home-instances"
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8" 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" /> <HomeGreeting :player-name="playerName" variant="minimal" />
<template v-if="selectedInstance"> <template v-if="selectedInstance">
@@ -248,13 +239,17 @@ onUnmounted(() => {
</router-link> </router-link>
<div class="flex min-h-11 shrink-0 items-center justify-end gap-2"> <div class="flex min-h-11 shrink-0 items-center justify-end gap-2">
<ButtonStyled v-if="running" color="red" size="large"> <ButtonStyled v-if="running" color="red" size="large" circular>
<button class="w-36 justify-center" @click="stopInstance"> <button
v-tooltip="formatMessage(messages.stopAll)"
:aria-label="formatMessage(messages.stopAll)"
:disabled="busy"
@click="stopInstance"
>
<StopCircleIcon aria-hidden="true" /> <StopCircleIcon aria-hidden="true" />
<span class="truncate">{{ formatMessage(commonMessages.stopButton) }}</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else-if="busy" size="large"> <ButtonStyled v-if="busy" size="large">
<button class="w-36 justify-center" disabled> <button class="w-36 justify-center" disabled>
<SpinnerIcon class="animate-spin" aria-hidden="true" /> <SpinnerIcon class="animate-spin" aria-hidden="true" />
<span class="truncate"> <span class="truncate">
@@ -267,7 +262,9 @@ onUnmounted(() => {
<ButtonStyled v-else-if="installed" color="brand" size="large"> <ButtonStyled v-else-if="installed" color="brand" size="large">
<button class="w-36 justify-center" @click="playInstance"> <button class="w-36 justify-center" @click="playInstance">
<PlayIcon class="translate-x-px" aria-hidden="true" /> <PlayIcon class="translate-x-px" aria-hidden="true" />
<span class="truncate">{{ formatMessage(commonMessages.playButton) }}</span> <span class="truncate">{{
formatMessage(running ? messages.launchAnother : commonMessages.playButton)
}}</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else color="brand" size="large"> <ButtonStyled v-else color="brand" size="large">
@@ -22,7 +22,6 @@ import { computed, ref } from 'vue'
import type { HomeWidgetSize } from '@/components/home/home-dashboard' import type { HomeWidgetSize } from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime' import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill } from '@/helpers/instance' import { kill } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
@@ -107,11 +106,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
try { try {
await start_join_server(world.instance_id, world.address) await start_join_server(world.instance_id, world.address)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedServer',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@@ -125,11 +119,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
async function stopInstance(instance: GameInstance) { async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedServer',
})
} }
async function unpinServer(world: ServerWorld & WorldWithInstance) { async function unpinServer(world: ServerWorld & WorldWithInstance) {
@@ -7,7 +7,6 @@ import { getHomeWidgetCardDensity, type HomeWidgetSize } from '@/components/home
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime' import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
@@ -73,11 +72,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
try { try {
await start_join_singleplayer_world(world.instance_id, world.path) await start_join_singleplayer_world(world.instance_id, world.path)
playingWorldKey.value = key playingWorldKey.value = key
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@@ -92,11 +86,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
async function playInstance(instance: GameInstance) { async function playInstance(instance: GameInstance) {
try { try {
await run(instance.id) await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@@ -109,11 +98,6 @@ async function playInstance(instance: GameInstance) {
async function stopInstance(instance: GameInstance) { async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
playingWorldKey.value = null playingWorldKey.value = null
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} }
</script> </script>
@@ -15,7 +15,6 @@ import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtim
import InstanceItem from '@/components/ui/world/InstanceItem.vue' import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
@@ -37,6 +36,7 @@ const props = withDefaults(
limit?: HomeRecentLimit limit?: HomeRecentLimit
}>(), }>(),
{ {
dashboardSize: null,
limit: HOME_RECENT_DEFAULT_LIMIT, limit: HOME_RECENT_DEFAULT_LIMIT,
}, },
) )
@@ -117,11 +117,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
await start_join_singleplayer_world(world.instance_id, world.path) await start_join_singleplayer_world(world.instance_id, world.path)
} }
playingWorldKey.value = key playingWorldKey.value = key
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@@ -136,11 +131,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
async function playInstance(instance: GameInstance) { async function playInstance(instance: GameInstance) {
try { try {
await run(instance.id) await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id, instance_id: instance.id,
@@ -153,20 +143,21 @@ async function playInstance(instance: GameInstance) {
async function stopInstance(instance: GameInstance) { async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError) await kill(instance.id).catch(handleError)
playingWorldKey.value = null playingWorldKey.value = null
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} }
</script> </script>
<template> <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"> <div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
<h2>{{ formatMessage(messages.recentTitle) }}</h2> <h2>{{ formatMessage(messages.recentTitle) }}</h2>
</div> </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 <template
v-for="item in recentItems" v-for="item in recentItems"
:key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`" :key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`"
@@ -28,7 +28,6 @@ import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime' import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
@@ -205,11 +204,6 @@ async function playInstance(targetInstance: GameInstance) {
starting.value = true starting.value = true
try { try {
await run(targetInstance.id) await run(targetInstance.id)
trackEvent('InstanceStart', {
loader: targetInstance.loader,
game_version: targetInstance.game_version,
source: 'HomeInstanceWidget',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: targetInstance.id, instance_id: targetInstance.id,
@@ -230,11 +224,6 @@ async function playWorld() {
} else { } else {
await start_join_singleplayer_world(instance.value.id, world.value.path) await start_join_singleplayer_world(instance.value.id, world.value.path)
} }
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'HomeShortcutWidget',
})
} catch (error) { } catch (error) {
const handled = await handleMinecraftLaunchError(error, { const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.value.id, instance_id: instance.value.id,
@@ -0,0 +1,161 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
max-width="560px"
:on-hide="handleHide"
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.gameDirLabel) }}
</span>
<div class="flex gap-2">
<StyledInput
class="flex-1"
:model-value="selectedPath"
readonly
:placeholder="defaultPath || formatMessage(messages.noSelection)"
/>
<ButtonStyled>
<button type="button" @click="browse">
<FolderOpenIcon />
{{ formatMessage(messages.browse) }}
</button>
</ButtonStyled>
</div>
<button
v-if="defaultPath && selectedPath !== defaultPath"
type="button"
class="self-start text-sm text-brand underline decoration-transparent underline-offset-2 transition-colors hover:decoration-current"
@click="selectedPath = defaultPath"
>
{{ formatMessage(messages.resetDefault) }}
</button>
</div>
<p v-if="previewPath" class="m-0 text-sm text-secondary break-all">
{{ formatMessage(messages.preview, { path: previewPath }) }}
</p>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="!selectedPath" @click="handleConfirm">
<CheckIcon />
{{ formatMessage(messages.confirm) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectFilePicker,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
const emit = defineEmits<{
(e: 'confirm', gameDirRoot: string): void
(e: 'cancel'): void
}>()
const { formatMessage } = useVIntl()
const filePicker = injectFilePicker()
const modal = ref<InstanceType<typeof NewModal>>()
const defaultPath = ref('')
const selectedPath = ref('')
const accepted = ref(false)
const messages = defineMessages({
header: {
id: 'app.hosted-install.game-dir.header',
defaultMessage: 'Choose game directory',
},
description: {
id: 'app.hosted-install.game-dir.description',
defaultMessage:
'StarLight instance data (mods, saves, configs, resource packs) is stored in an external game directory. Pick a root folder — the modpack gets its own subfolder inside it.',
},
gameDirLabel: {
id: 'app.hosted-install.game-dir.label',
defaultMessage: 'Game directory root',
},
browse: {
id: 'app.hosted-install.game-dir.browse',
defaultMessage: 'Browse',
},
noSelection: {
id: 'app.hosted-install.game-dir.no-selection',
defaultMessage: 'No folder selected',
},
resetDefault: {
id: 'app.hosted-install.game-dir.reset-default',
defaultMessage: 'Use default location',
},
preview: {
id: 'app.hosted-install.game-dir.preview',
defaultMessage: 'Game files will be installed to: {path}',
},
confirm: {
id: 'app.hosted-install.game-dir.confirm',
defaultMessage: 'Install',
},
})
const previewPath = computed(() => {
const base = (selectedPath.value ?? '').replace(/[\\/]+$/, '')
return base ? `${base}/<pack name>` : ''
})
async function show() {
if (!defaultPath.value) {
defaultPath.value = await invoke<string>('get_launcher_root_dir').catch(() => '')
}
if (!selectedPath.value) selectedPath.value = defaultPath.value
accepted.value = false
modal.value?.show()
}
async function browse() {
const picked = await filePicker.pickFolder?.()
if (picked?.path) selectedPath.value = picked.path
}
function handleCancel() {
modal.value?.hide()
}
function handleConfirm() {
accepted.value = true
modal.value?.hide()
emit('confirm', selectedPath.value)
}
function handleHide() {
if (!accepted.value) emit('cancel')
}
defineExpose({ show })
</script>
@@ -0,0 +1,208 @@
<template>
<InstanceModeSettings :instance-id="instanceId" :disabled="syncing" class="mb-5" />
<div v-if="modeQuery.data.value === 'local'" class="flex flex-col gap-3">
<h2 class="m-0">{{ formatMessage(messages.localTitle) }}</h2>
<p class="m-0 text-secondary">{{ formatMessage(messages.localPacks) }}</p>
<div class="flex flex-wrap gap-3">
<ButtonStyled
><button type="button" @click="router.push('/browse/modpack')">
{{ formatMessage(messages.browse) }}
</button></ButtonStyled
>
<ButtonStyled
><button type="button" @click="importLocal">
{{ formatMessage(messages.importLocal) }}
</button></ButtonStyled
>
</div>
</div>
<template v-if="modeQuery.data.value === 'starlight'">
<section class="flex flex-col gap-4" :aria-busy="loading || syncing">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0">{{ formatMessage(messages.title) }}</h2>
<p>{{ formatMessage(messages.description) }}</p>
</div>
<ButtonStyled
><button type="button" :disabled="loading || syncing" @click="load">
{{ formatMessage(messages.refresh) }}
</button></ButtonStyled
>
</div>
<p v-if="loading" role="status">{{ formatMessage(messages.loading) }}</p>
<p v-if="error" role="alert" class="text-red">{{ error }}</p>
<p v-if="binding">
{{
formatMessage(messages.bound, {
name: binding.publication.manifest.name,
version: binding.publication.manifest.version,
})
}}
</p>
<HostedPackProgress :instance-id="instanceId" :active="syncing" />
<div v-if="result" role="status" class="rounded-xl bg-bg-raised p-4">
<p>
{{
formatMessage(messages.complete, {
version: result.version,
count: result.changedFiles,
size: (result.downloadedBytes / 1048576).toFixed(2),
})
}}
</p>
<details v-if="result.preservedFiles.length">
<summary>
{{ formatMessage(messages.preserved, { count: result.preservedFiles.length }) }}
</summary>
<ul>
<li v-for="path in result.preservedFiles" :key="path">{{ path }}</li>
</ul>
</details>
</div>
<article
v-if="pack"
class="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-bg-raised p-4"
>
<div>
<h3 class="m-0">{{ pack.manifest.name }}</h3>
<p class="mb-0">
{{ pack.manifest.version }} · Minecraft {{ pack.manifest.runtime.gameVersion }} ·
{{ pack.manifest.runtime.loader }}
</p>
</div>
<ButtonStyled color="brand"
><button type="button" :disabled="!ready || syncing || loading" @click="sync">
{{ formatMessage(binding ? messages.update : messages.install) }}
</button></ButtonStyled
>
</article>
</section>
</template>
</template>
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import { markHostedCreationCompleted } from '@/composables/useHostedCreation'
import { useHostedSync } from '@/composables/useHostedSync'
import { useInstanceMode } from '@/composables/useInstanceMode'
import {
type HostedBinding,
hostedBinding,
hostedDefault,
type HostedPublication,
} from '@/helpers/hosted-packs'
import { injectDownloadManager } from '@/providers/download-manager'
const props = defineProps<{ instanceId: string }>()
const modeQuery = useInstanceMode(() => props.instanceId)
const router = useRouter()
const showCreation = inject<(options: { skipSetupType: boolean; initialMode: 'import' }) => void>(
'showCreationModalWithOptions',
)
function importLocal() {
showCreation?.({ skipSetupType: true, initialMode: 'import' })
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
localTitle: { id: 'app.instance-mode.local-packs-title', defaultMessage: 'Local modpacks' },
localPacks: {
id: 'app.instance-mode.local-packs',
defaultMessage:
'Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized.',
},
browse: { id: 'app.instance-mode.browse', defaultMessage: 'Browse modpacks' },
importLocal: { id: 'app.instance-mode.import', defaultMessage: 'Import as a local instance' },
title: { id: 'app.hosted-packs.title', defaultMessage: 'Server-managed modpack' },
description: {
id: 'app.hosted-packs.description',
defaultMessage:
'The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required.',
},
refresh: { id: 'app.hosted-packs.refresh', defaultMessage: 'Refresh' },
loading: { id: 'app.hosted-packs.loading', defaultMessage: 'Loading published modpacks…' },
bound: { id: 'app.hosted-packs.bound', defaultMessage: 'Installed: {name} · {version}' },
syncing: {
id: 'app.hosted-packs.syncing',
defaultMessage: 'Comparing files and synchronizing changes…',
},
complete: {
id: 'app.hosted-packs.complete',
defaultMessage: 'Synced to {version}. Changed {count} files; downloaded {size} MiB.',
},
preserved: {
id: 'app.hosted-packs.preserved',
defaultMessage: 'Preserved {count} locally modified or personal files',
},
empty: {
id: 'app.hosted-packs.empty',
defaultMessage: 'No modpacks have been approved for publication yet.',
},
update: { id: 'app.hosted-packs.update', defaultMessage: 'Synchronize now' },
install: { id: 'app.hosted-packs.install', defaultMessage: 'Retry automatic installation' },
})
const pack = ref<HostedPublication | null>(null)
const binding = ref<HostedBinding | null>(null)
const task = useHostedSync(() => props.instanceId)
const result = task.result
const manager = injectDownloadManager()
const loading = ref(false)
const syncing = computed(
() =>
task.busy.value ||
manager.legacyDownloads.value.some(
(bar) =>
bar.bar_type?.type === 'hosted_pack_sync' &&
bar.bar_type.instance_id === props.instanceId &&
!bar.bar_type.error,
),
)
const ready = ref(false)
const loadError = ref('')
const error = computed(() => loadError.value || task.error.value)
let generation = 0
async function load() {
if (modeQuery.data.value !== 'starlight') return
const current = ++generation
const instanceId = props.instanceId
loading.value = true
ready.value = false
loadError.value = ''
try {
const [official, installed] = await Promise.all([hostedDefault(), hostedBinding(instanceId)])
if (current !== generation) return
pack.value = official
binding.value = installed
ready.value = true
} catch (cause) {
if (current === generation) loadError.value = String(cause)
} finally {
if (current === generation) loading.value = false
}
}
async function sync() {
if (syncing.value || !ready.value || modeQuery.data.value !== 'starlight') return
loadError.value = ''
const result = await task.sync()
if (result) markHostedCreationCompleted(props.instanceId)
}
watch(syncing, (busy, wasBusy) => {
if (!busy && wasBusy) void load()
})
watch(
() => [props.instanceId, modeQuery.data.value] as const,
() => {
generation++
loading.value = false
ready.value = false
loadError.value = ''
pack.value = null
binding.value = null
void load()
},
{ immediate: true },
)
</script>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { injectDownloadManager } from '@/providers/download-manager'
const props = defineProps<{ instanceId?: string; active?: boolean }>()
const manager = injectDownloadManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
preparing: {
id: 'app.hosted-packs.progress.preparing',
defaultMessage: 'Preparing modpack installation…',
},
downloads: { id: 'app.hosted-packs.progress.downloads', defaultMessage: 'View downloads' },
})
const bar = computed(() =>
manager.legacyDownloads.value.find(
(item) =>
props.instanceId &&
item.bar_type?.type === 'hosted_pack_sync' &&
!item.bar_type.error &&
item.bar_type.instance_id === props.instanceId,
),
)
const waiting = computed(() => !bar.value?.total)
const current = computed(() =>
Math.max(0, Math.min(bar.value?.current ?? 0, bar.value?.total ?? 0)),
)
const message = computed(() => bar.value?.message || formatMessage(messages.preparing))
</script>
<template>
<div v-if="active || bar" class="flex min-w-0 flex-col gap-2" role="status">
<ProgressBar
:progress="current"
:max="bar?.total || 1"
:waiting="waiting"
:label="message"
label-class="min-w-0 break-all text-sm text-secondary"
:show-progress="!waiting"
full-width
>
<template #progress-icon />
</ProgressBar>
<RouterLink to="/downloads" class="self-start text-sm text-brand hover:underline">
{{ formatMessage(messages.downloads) }}
</RouterLink>
</div>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { useId } from 'vue'
import type { InstanceMode } from '@/helpers/hosted-packs'
defineProps<{ modelValue?: InstanceMode; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: InstanceMode] }>()
const group = useId()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.instance-mode.title', defaultMessage: 'Instance type' },
starlight: { id: 'app.instance-mode.starlight', defaultMessage: 'StarLight instance' },
local: { id: 'app.instance-mode.local', defaultMessage: 'Local instance' },
starlightDescription: {
id: 'app.instance-mode.starlight-description',
defaultMessage:
'Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting.',
},
localDescription: {
id: 'app.instance-mode.local-description',
defaultMessage:
'Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds.',
},
})
</script>
<template>
<fieldset class="m-0 flex flex-col gap-3 border-0 p-0" :disabled="disabled">
<legend class="mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</legend>
<label
v-for="mode in ['starlight', 'local'] as const"
:key="mode"
class="flex items-start gap-3 rounded-xl border border-solid border-divider p-4"
:class="[
modelValue === mode ? 'bg-surface-3' : 'bg-surface-1',
disabled ? 'opacity-60' : 'cursor-pointer',
]"
>
<input
type="radio"
class="mt-1"
:name="group"
:value="mode"
:checked="modelValue === mode"
@change="emit('update:modelValue', mode)"
/>
<span class="flex flex-col gap-1"
><span class="font-semibold text-contrast">{{ formatMessage(messages[mode]) }}</span
><span class="text-sm text-secondary">{{
formatMessage(
mode === 'starlight' ? messages.starlightDescription : messages.localDescription,
)
}}</span></span
>
</label>
</fieldset>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, watch } from 'vue'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import { useInstanceMode, useSetInstanceMode } from '@/composables/useInstanceMode'
import type { InstanceMode } from '@/helpers/hosted-packs'
const props = defineProps<{ instanceId: string; disabled?: boolean }>()
const query = useInstanceMode(() => props.instanceId)
const save = useSetInstanceMode()
const { formatMessage } = useVIntl()
const messages = defineMessages({
saving: {
id: 'app.instance-mode.saving',
defaultMessage: 'Applying instance type and installing the server modpack when needed…',
},
loading: { id: 'app.instance-mode.loading', defaultMessage: 'Loading instance type…' },
retry: { id: 'app.instance-mode.retry', defaultMessage: 'Retry' },
})
const saving = computed(
() => save.isPending.value && save.variables.value?.instanceId === props.instanceId,
)
const selection = computed(() => (saving.value ? save.variables.value?.mode : query.data.value))
const failure = computed(
() =>
query.error.value ??
(save.variables.value?.instanceId === props.instanceId ? save.error.value : null),
)
function select(mode: InstanceMode) {
if (props.disabled || saving.value || !query.data.value || query.data.value === mode) return
save.mutate({ instanceId: props.instanceId, mode })
}
function retry() {
if (props.disabled || saving.value) return
if (query.error.value) void query.refetch()
else if (save.variables.value?.instanceId === props.instanceId) save.mutate(save.variables.value)
}
watch(
() => props.instanceId,
() => save.reset(),
)
</script>
<template>
<div class="flex flex-col gap-3">
<InstanceModeOptions
:model-value="selection"
:disabled="disabled || !query.data.value || query.isPending.value || saving"
@update:model-value="select"
/>
<p v-if="query.isPending.value || saving" class="m-0 text-secondary" role="status">
{{ formatMessage(saving ? messages.saving : messages.loading) }}
</p>
<div v-if="failure" role="alert" class="flex items-center gap-3">
<span>{{ String(failure) }}</span
><ButtonStyled
><button
type="button"
:disabled="disabled || query.isFetching.value || saving"
@click="retry"
>
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
</div>
</div>
</template>
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
skinSitePlayers,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { users } from '@/helpers/auth'
import { getInstanceMode } from '@/helpers/hosted-packs'
import {
type InstancePlayer,
type PlayerChoice,
PlayerSelectionNavigatedAwayError,
registerInstancePlayerPicker,
saveInstancePlayer,
waitForSkinSiteSession,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
const router = useRouter()
const accounts = ref<PlayerChoice[]>([])
const active = ref(false)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const instanceId = ref('')
const locked = ref<InstancePlayer | null>(null)
const awaitingSkinLogin = ref(false)
let hideForLogin = false
let generation = 0
let resolveSelection: ((player: InstancePlayer) => void) | undefined
let rejectSelection: ((reason: Error) => void) | undefined
const choices = computed(() => {
const skin: PlayerChoice[] =
skinSiteStatus.value === 'signed-in'
? skinSitePlayers.value
.filter((player) => !player.isMojang)
.map((player) => ({
id: player.uuid,
name: player.name,
account_type: 'yggdrasil',
skin_site_user: skinSiteUser.value?.uuid,
head: player.headDataUrl,
}))
: []
return [...skin, ...accounts.value].filter(
(player) =>
!locked.value ||
(player.id.replaceAll('-', '') === locked.value.id.replaceAll('-', '') &&
player.account_type === locked.value.account_type &&
(player.skin_site_user ?? null) === (locked.value.skin_site_user ?? null)),
)
})
async function refresh() {
const revision = ++generation
loading.value = true
error.value = ''
try {
const [available, mode] = await Promise.all([users(), getInstanceMode(instanceId.value)])
if (revision !== generation) return
accounts.value = available
.filter(
(account) =>
account.account_type === 'microsoft' ||
(mode === 'local' && account.account_type === 'offline'),
)
.map((account) => ({
id: account.profile.id,
name: account.profile.name,
account_type: account.account_type,
}))
await waitForSkinSiteSession()
if (revision !== generation) return
if (skinSiteStatus.value === 'signed-in') await requestSkinSitePlayers()
if (skinSiteStatus.value === 'error') throw new Error('无法确认皮肤站登录状态,请重试。')
} catch (cause) {
if (revision === generation)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (revision === generation) loading.value = false
}
}
function cancelled() {
if (hideForLogin) {
hideForLogin = false
return
}
if (!active.value) return
active.value = false
awaitingSkinLogin.value = false
generation++
rejectSelection?.(new Error('已取消选择实例玩家'))
resolveSelection = undefined
rejectSelection = undefined
}
async function select(player: PlayerChoice) {
if (loading.value || saving.value || !active.value) return
saving.value = true
error.value = ''
try {
const { head: _, ...binding } = player
await saveInstancePlayer(instanceId.value, binding)
active.value = false
resolveSelection?.(binding)
resolveSelection = undefined
rejectSelection = undefined
modal.value?.hide()
} catch (cause) {
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
saving.value = false
}
}
function signInSkinSite() {
awaitingSkinLogin.value = true
hideForLogin = true
active.value = false
generation++
// Settle the pending selection so `prepareInstancePlayer` releases its
// in-flight entry; otherwise a later launch would await this forever and
// never re-prompt. The caller treats this sentinel as a silent abort.
rejectSelection?.(new PlayerSelectionNavigatedAwayError())
resolveSelection = undefined
rejectSelection = undefined
openSkinSiteLogin()
modal.value?.hide()
void router.push('/starlight-skin')
}
const unregister = registerInstancePlayerPicker((id, saved) => {
if (active.value) return Promise.reject(new Error('请先完成当前实例的玩家选择。'))
instanceId.value = id
locked.value = saved
accounts.value = []
awaitingSkinLogin.value = false
active.value = true
modal.value?.show()
void refresh()
return new Promise((resolve, reject) => {
resolveSelection = resolve
rejectSelection = reject
})
})
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], () => {
if (!active.value || saving.value) return
if (
awaitingSkinLogin.value &&
skinSiteStatus.value === 'signed-in' &&
(!locked.value?.skin_site_user || locked.value.skin_site_user === skinSiteUser.value?.uuid)
) {
awaitingSkinLogin.value = false
modal.value?.show()
}
void refresh()
})
onUnmounted(() => {
unregister()
cancelled()
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:closable="!saving"
:on-hide="cancelled"
>
<div class="flex flex-col gap-3">
<p class="m-0 text-secondary">
{{
locked
? formatMessage(messages.restore, { name: locked.name })
: formatMessage(messages.remember)
}}
</p>
<p v-if="loading" class="m-0" role="status">{{ formatMessage(messages.loading) }}</p>
<template v-else>
<ButtonStyled v-for="player in choices" :key="`${player.account_type}:${player.id}`">
<button
type="button"
:disabled="saving"
class="flex items-center gap-3"
@click="select(player)"
>
<img
v-if="player.head"
:src="player.head"
alt=""
class="size-8 rounded-md [image-rendering:pixelated]"
/>
<span>{{ player.name }}</span
><span class="text-secondary">{{
formatMessage(
player.account_type === 'microsoft'
? messages.licensed
: player.account_type === 'offline'
? messages.offline
: messages.skin,
)
}}</span>
</button>
</ButtonStyled>
<p v-if="!choices.length && !error && !locked" class="m-0 text-secondary">
{{ formatMessage(skinSiteStatus === 'signed-in' ? messages.empty : messages.signIn) }}
</p>
</template>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
<ButtonStyled v-if="error"
><button :disabled="loading || saving" @click="refresh">
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
<ButtonStyled
v-if="
skinSiteStatus === 'signed-out' ||
(locked?.skin_site_user && locked.skin_site_user !== skinSiteUser?.uuid)
"
><button :disabled="saving" @click="signInSkinSite">
{{ formatMessage(messages.skinLogin) }}
</button></ButtonStyled
>
<div class="flex flex-col items-center gap-2">
<span class="text-secondary">{{ formatMessage(messages.or) }}</span>
<ButtonStyled
><button :disabled="loading || saving" @click="microsoftLogin?.showDeviceLogin()">
{{ formatMessage(messages.useMicrosoft) }}
</button></ButtonStyled
>
</div>
<p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p>
</div>
</NewModal>
<MinecraftLoginModal ref="microsoftLogin" @complete="refresh" />
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
chooseInstancePlayer,
getInstancePlayer,
type InstancePlayer,
onInstancePlayerChanged,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const props = defineProps<{ instanceId: string }>()
const player = ref<InstancePlayer | null>(null)
const busy = ref(false)
const error = ref('')
let generation = 0
const stop = onInstancePlayerChanged((id, saved) => {
if (id === props.instanceId) {
generation++
player.value = saved
busy.value = false
error.value = ''
}
})
onUnmounted(() => {
generation++
stop()
})
watch(
() => props.instanceId,
async (id) => {
const revision = ++generation
player.value = null
error.value = ''
busy.value = true
try {
const saved = await getInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision) error.value = String(cause)
} finally {
if (generation === revision) busy.value = false
}
},
{ immediate: true },
)
async function change() {
if (busy.value) return
const revision = generation
const id = props.instanceId
busy.value = true
error.value = ''
try {
const saved = await chooseInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (generation === revision) busy.value = false
}
}
</script>
<template>
<div class="mb-6 flex flex-col gap-2" data-onboarding-id="instance-player-settings">
<h3 class="m-0 text-contrast">{{ formatMessage(messages.setting) }}</h3>
<p class="m-0 text-secondary">
{{ player ? player.name : formatMessage(messages.firstLaunch) }}
</p>
<ButtonStyled
><button :disabled="busy" @click="change">
{{ formatMessage(busy ? messages.loading : messages.change) }}
</button></ButtonStyled
>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
</div>
</template>
@@ -0,0 +1,73 @@
<template>
<div
class="management-switch relative isolate mb-4 flex w-full max-w-[26rem] rounded-xl bg-surface-1 p-1"
:data-packs="selected === 'packs'"
role="group"
:aria-label="formatMessage(messages.label)"
>
<span
class="selection absolute inset-y-1 left-1 -z-10 w-[calc(50%_-_4px)] rounded-lg bg-surface-3 shadow-sm"
aria-hidden="true"
/>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'mods'"
@click="select('mods')"
>
{{ formatMessage(messages.mods) }}
</button>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'packs'"
@click="select('packs')"
>
{{ formatMessage(messages.packs) }}
</button>
</div>
<div v-show="selected === 'mods'"><slot /></div>
<div v-if="openedPacks" v-show="selected === 'packs'"><slot name="packs" /></div>
</template>
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
label: { id: 'app.hosted-packs.management', defaultMessage: 'Content management' },
mods: { id: 'app.hosted-packs.mods', defaultMessage: 'Manage mods' },
packs: { id: 'app.hosted-packs.packs', defaultMessage: 'Manage modpacks' },
})
const key = 'starlight:instance:mod-management-tab'
function initial(): 'mods' | 'packs' {
try {
return localStorage.getItem(key) === 'packs' ? 'packs' : 'mods'
} catch {
return 'mods'
}
}
const selected = ref(initial())
const openedPacks = ref(selected.value === 'packs')
function select(value: 'mods' | 'packs') {
selected.value = value
if (value === 'packs') openedPacks.value = true
try {
localStorage.setItem(key, value)
} catch {
/* Selection remains usable without storage. */
}
}
</script>
<style scoped>
.selection {
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-packs='true'] .selection {
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.selection {
transition: none;
}
}
</style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
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 { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
import { progress_bars_list } from '@/helpers/state'
import {
createTaggedModProgress,
type TaggedProgressEvent,
type TaggedProgressGroup,
} from '@/helpers/tagged-mod-progress'
const modal = ref<InstanceType<typeof NewModal>>()
const router = useRouter()
const { formatMessage } = useVIntl()
const state = createTaggedModProgress()
const groups = shallowRef<TaggedProgressGroup[]>([])
const messages = defineMessages({
title: { id: 'app.hosted-mods.title', defaultMessage: 'Updating server Mods' },
description: {
id: 'app.hosted-mods.description',
defaultMessage: 'The game starts after all required updates have been installed.',
},
complete: { id: 'app.hosted-mods.complete', defaultMessage: 'Downloaded' },
downloads: { id: 'app.hosted-mods.downloads', defaultMessage: 'View downloads' },
close: { id: 'app.hosted-mods.close', defaultMessage: 'Close' },
})
const size = (value: number) => `${(value / 1048576).toFixed(1)} MiB`
let disposed = false
let unlisten: (() => void) | undefined
let stopAttempts: (() => void) | undefined
function refresh() {
groups.value = [...state.groups.values()]
}
function consume(payload: TaggedProgressEvent) {
const opened = state.update(payload)
refresh()
if (opened)
void nextTick(() => {
const group = state.groups.get(payload.event?.batch_id ?? '')
if (!disposed && group && (!group.done || group.error)) modal.value?.show()
})
if (groups.value.length > 0 && groups.value.every((group) => group.done && !group.error))
modal.value?.hide()
}
onMounted(async () => {
stopAttempts = onHostedPackAttemptStarted((id) => {
state.reset(id)
refresh()
if (!groups.value.length) modal.value?.hide()
})
let initializing = true
const buffered: TaggedProgressEvent[] = []
const stop = await loading_listener((payload: TaggedProgressEvent) => {
if (initializing) buffered.push(payload)
else consume(payload)
})
if (disposed) {
stop()
return
}
unlisten = stop
const bars = await progress_bars_list().catch(() => ({}))
if (disposed) return
const ordered = Object.values(bars).sort(
(a, b) =>
Number(b.bar_type?.type === 'hosted_pack_sync') -
Number(a.bar_type?.type === 'hosted_pack_sync'),
)
for (const bar of ordered)
consume({
loader_uuid: String(bar.loading_bar_uuid),
event: bar.bar_type,
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
total: bar.total,
message: bar.message ?? '',
})
initializing = false
for (const payload of buffered) consume(payload)
})
onUnmounted(() => {
disposed = true
unlisten?.()
stopAttempts?.()
})
function openDownloads() {
modal.value?.hide()
void router.push('/downloads')
}
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="min(38rem, calc(100vw - 2rem))"
scrollable
max-content-height="60vh"
:close-on-click-outside="false"
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
<section v-for="group in groups" :key="group.id" class="flex min-w-0 flex-col gap-3">
<h3 class="m-0 text-contrast">{{ group.name }}</h3>
<p v-if="group.error" role="alert" class="m-0 break-words text-red">{{ group.error }}</p>
<p v-else-if="group.message" class="m-0 break-words text-sm text-secondary">
{{ group.message }}
</p>
<div
v-for="file in group.files.values()"
:key="file.id"
class="flex min-w-0 flex-col gap-1"
>
<div class="flex flex-wrap justify-between gap-2 text-sm">
<span class="min-w-0 break-all text-contrast">{{ file.name }}</span>
<span class="text-secondary tabular-nums"
>{{ size(file.current) }} / {{ size(file.total) }}</span
>
</div>
<p v-if="file.error" class="m-0 break-words text-sm text-red">{{ file.error }}</p>
<ProgressBar
v-else
:progress="file.current"
:max="file.total || 1"
:waiting="!file.total"
:show-progress="!file.done"
:label="file.done ? formatMessage(messages.complete) : file.message"
full-width
><template #progress-icon
/></ProgressBar>
</div>
</section>
</div>
<template #actions>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled>
<button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal?.hide()">
{{ formatMessage(messages.close) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,37 @@
import { defineMessages } from '@modrinth/ui'
export const playerMessages = defineMessages({
title: { id: 'app.instance-player.title', defaultMessage: 'Choose an instance player' },
remember: {
id: 'app.instance-player.remember',
defaultMessage:
'This instance will keep using your choice. Switch players in instance settings.',
},
restore: {
id: 'app.instance-player.restore',
defaultMessage: 'Sign in again as {name}. To choose someone else, open instance settings.',
},
loading: { id: 'app.instance-player.loading', defaultMessage: 'Loading signed-in players…' },
licensed: { id: 'app.instance-player.licensed', defaultMessage: 'Minecraft account' },
skin: { id: 'app.instance-player.skin', defaultMessage: 'Skin site player' },
offline: { id: 'app.instance-player.offline', defaultMessage: 'Offline player' },
empty: {
id: 'app.instance-player.empty',
defaultMessage: 'No available skin site players. You can create a player on the skin site.',
},
signIn: { id: 'app.instance-player.sign-in', defaultMessage: 'Sign in to choose a player.' },
retry: { id: 'app.instance-player.retry', defaultMessage: 'Retry' },
skinLogin: { id: 'app.instance-player.skin-login', defaultMessage: 'Sign in to skin site' },
or: { id: 'app.instance-player.or', defaultMessage: 'Or' },
useMicrosoft: {
id: 'app.instance-player.use-microsoft',
defaultMessage: 'Use a Minecraft account',
},
saving: { id: 'app.instance-player.saving', defaultMessage: 'Saving instance player…' },
setting: { id: 'app.instance-player.setting', defaultMessage: 'Instance player' },
firstLaunch: {
id: 'app.instance-player.first-launch',
defaultMessage: 'Choose a player on first launch',
},
change: { id: 'app.instance-player.change', defaultMessage: 'Switch instance player' },
})
@@ -12,10 +12,14 @@
> >
{{ formatMessage(messages.score, { score }) }} {{ formatMessage(messages.score, { score }) }}
</span> </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 }) }} {{ formatMessage(messages.highestLevel, { level: highestLevel + 1 }) }}
</span> </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 }) }} {{ formatMessage(messages.best, { score: bestScore }) }}
</span> </span>
<NewButton type="base" size="sm" class="shrink-0" @click="settleGame"> <NewButton type="base" size="sm" class="shrink-0" @click="settleGame">
@@ -80,7 +84,10 @@
</div> </div>
</div> </div>
</Transition> </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"> <Transition name="panel">
<div <div
v-if="gameOver" v-if="gameOver"
@@ -115,16 +122,26 @@
</div> </div>
<div class="grid w-full min-w-0 grid-cols-3 gap-1.5"> <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"> <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-[9px] text-secondary">{{
<span class="text-sm font-bold tabular-nums text-contrast">{{ highestLevel + 1 }}</span> formatMessage(messages.levelLabel)
}}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{
highestLevel + 1
}}</span>
</div> </div>
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1"> <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> <span class="text-sm font-bold tabular-nums text-contrast">{{ bestScore }}</span>
</div> </div>
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1"> <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-[9px] text-secondary">{{
<span class="text-sm font-bold tabular-nums text-contrast">{{ overtimeCount }}</span> formatMessage(messages.overtimesLabel)
}}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{
overtimeCount
}}</span>
</div> </div>
</div> </div>
<div class="flex flex-wrap justify-center gap-2"> <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 greenStar from '@/assets/stars/green.png'
import pinkStar from '@/assets/stars/pink.png' import pinkStar from '@/assets/stars/pink.png'
import redStar from '@/assets/stars/red.png' import redStar from '@/assets/stars/red.png'
import yellowStar from '@/assets/stars/yellow.png'
import superStar from '@/assets/stars/super.png' import superStar from '@/assets/stars/super.png'
import yellowStar from '@/assets/stars/yellow.png'
interface Piece { interface Piece {
id: number id: number
@@ -396,7 +413,14 @@ function mergePieces(a: Piece, b: Piece) {
score.value += points[level] score.value += points[level]
highestLevel.value = Math.max(highestLevel.value, level) highestLevel.value = Math.max(highestLevel.value, level)
const isRainbow = level === colors.length - 1 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)) 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)) shakePower = Math.max(shakePower, Math.min(2 + level * 1.2, 7))
spawnFloater(x, y, `+${points[level]}`, 15 + level * 3, colors[level]) 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) 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++) { for (let i = 0; i < count; i++) {
if (particles.length >= 260) return if (particles.length >= 260) return
const angle = Math.random() * Math.PI * 2 const angle = Math.random() * Math.PI * 2
@@ -508,7 +539,8 @@ function update(delta: number) {
if (overtimeStartAt >= 0) { if (overtimeStartAt >= 0) {
const overtimeElapsed = elapsed - overtimeStartAt const overtimeElapsed = elapsed - overtimeStartAt
if (overtimeElapsed < 0.8) { if (overtimeElapsed < 0.8) {
surfaceRatio = overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8) surfaceRatio =
overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8)
} else { } else {
overtimeStartAt = -1 overtimeStartAt = -1
} }
@@ -670,17 +702,7 @@ function prepareBallSprite(src: string, level: number) {
sprite.height = Math.max(1, Math.round(bounds.h * scale)) sprite.height = Math.max(1, Math.round(bounds.h * scale))
const ctx = sprite.getContext('2d') const ctx = sprite.getContext('2d')
if (!ctx) return if (!ctx) return
ctx.drawImage( ctx.drawImage(image, bounds.x, bounds.y, bounds.w, bounds.h, 0, 0, sprite.width, sprite.height)
image,
bounds.x,
bounds.y,
bounds.w,
bounds.h,
0,
0,
sprite.width,
sprite.height,
)
ballSprites[level] = sprite ballSprites[level] = sprite
} }
image.src = src image.src = src
@@ -692,9 +714,7 @@ function drawMergePiece(piece: Piece) {
const spawnProgress = Math.min(1, (elapsed - piece.spawnAt) / 0.22) const spawnProgress = Math.min(1, (elapsed - piece.spawnAt) / 0.22)
const spawnScale = spawnProgress >= 1 ? 1 : easeOutBack(spawnProgress) const spawnScale = spawnProgress >= 1 ? 1 : easeOutBack(spawnProgress)
const radius = const radius =
piece.radius * piece.radius * spawnScale * (isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
spawnScale *
(isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
context.save() context.save()
context.translate(piece.x, piece.y) context.translate(piece.x, piece.y)
const hue = (elapsed * 55) % 360 const hue = (elapsed * 55) % 360
@@ -792,9 +812,7 @@ function draw() {
context.beginPath() context.beginPath()
for (let x = 0; x <= width; x += 8) { for (let x = 0; x <= width; x += 8) {
const waveY = const waveY =
surfaceY + surfaceY + Math.sin(x * 0.045 + elapsed * 1.8) * 2.5 + Math.sin(x * 0.013 + elapsed * 0.9) * 2
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) if (x === 0) context.moveTo(x, waveY)
else context.lineTo(x, waveY) else context.lineTo(x, waveY)
} }
@@ -807,7 +825,12 @@ function draw() {
const progress = Math.min(1, (elapsed - waveStart) / 0.8) const progress = Math.min(1, (elapsed - waveStart) / 0.8)
const waveY = height * (1 - progress) const waveY = height * (1 - progress)
const bandHeight = Math.max(22, height * 0.1) 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, 'rgba(255, 255, 255, 0)')
gradient.addColorStop(0.55, 'rgba(96, 178, 255, 0.5)') gradient.addColorStop(0.55, 'rgba(96, 178, 255, 0.5)')
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)') gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
@@ -1,318 +1,44 @@
<template> <template>
<canvas id="about_scene" class="size-full" /> <canvas ref="canvas" class="about-scene" aria-hidden="true" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import * as THREE from 'three' import { onActivated, onDeactivated, onMounted, onScopeDispose, useTemplateRef, watch } from 'vue'
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
import { useTheming } from '@/store/theme' import { createWitherVictoryScene } from './about-scene/wither-victory'
const themeStore = useTheming() const props = defineProps<{ paused?: boolean }>()
function isDarkMode() { const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
if (themeStore.selectedTheme == 'system') { let scene: ReturnType<typeof createWitherVictoryScene> | undefined
return matchMedia('(prefers-color-scheme: dark)').matches let active = true
}
return ['dark', 'oled'].includes(themeStore.selectedTheme) function updatePlayback() {
scene?.setPaused(!active || Boolean(props.paused))
} }
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) { onMounted(() => {
const tipGeometry = new THREE.SphereGeometry(2) if (!canvas.value) return
const tipMaterial = new THREE.MeshBasicMaterial({ color }) scene = createWitherVictoryScene(canvas.value)
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial) updatePlayback()
tipMesh.position.copy(position) scene.ready.catch((error: unknown) => console.warn('Unable to load about-page scene', error))
return tipMesh })
} watch(() => props.paused, updatePlayback)
onActivated(() => {
function createWaterMaterial(): THREE.ShaderMaterial { active = true
return new THREE.ShaderMaterial({ updatePlayback()
uniforms: { })
time: { value: 0 }, onDeactivated(() => {
seed: { value: Math.random() * 83 + 17 }, active = false
color: { value: new THREE.Color(0.3, 0.3, 1.0) }, updatePlayback()
}, })
transparent: true, onScopeDispose(() => scene?.dispose())
vertexShader: `#define WATER_VERT
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `#define WATER_FRAG
uniform float time;
uniform float seed;
uniform vec3 color;
varying vec2 vUv;
vec2 randomGradient(vec2 p) {
float n = sin(dot(p, vec2(127.1, 311.7)));
float angle = fract(n * 43758.5453123) * 6.28318530718 * seed;
return vec2(cos(angle), sin(angle));
}
float perlinNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
vec2 g1 = randomGradient(i);
vec2 g2 = randomGradient(i + vec2(1.0, 0.0));
vec2 g3 = randomGradient(i + vec2(0.0, 1.0));
vec2 g4 = randomGradient(i + vec2(1.0, 1.0));
vec2 d1 = f;
vec2 d2 = f - vec2(1.0, 0.0);
vec2 d3 = f - vec2(0.0, 1.0);
vec2 d4 = f - vec2(1.0, 1.0);
float v1 = dot(g1, d1);
float v2 = dot(g2, d2);
float v3 = dot(g3, d3);
float v4 = dot(g4, d4);
return mix(mix(v1, v2, u.x), mix(v3, v4, u.x), u.y);
}
void main() {
float height = 0.0;
height += perlinNoise(vec2(vUv.x * 10.0, time * 0.8)) * 0.3;
height += perlinNoise(vec2(vUv.x * 5.0, time * 0.4)) * 0.35;
height += perlinNoise(vec2(vUv.x * 2.5, time * 0.2)) * 0.15;
height += perlinNoise(vec2(vUv.x * 2.0, time * 0.2)) * 0.2;
height = clamp(height, -1.0, 1.0);
height = height * 0.8 + 0.6;
float thickness = 0.008;
if(vUv.y < height - thickness) {
float scalar = 1.0 - height + vUv.y;
scalar = scalar * scalar * scalar * 0.6;
gl_FragColor = vec4(color, scalar);
} else if(vUv.y > height + thickness) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
} else {
gl_FragColor = vec4(color, 1.0);
}
}`,
})
}
function createWater(material: THREE.ShaderMaterial, position: THREE.Vector3) {
const geometry = new THREE.PlaneGeometry(120, 16)
const waterMesh = new THREE.Mesh(geometry, material)
waterMesh.position.copy(position)
return waterMesh
}
function createCircleMaterial(): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
uniforms: {
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
},
transparent: true,
vertexShader: `#define CIRCLE_VERT
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `#define CIRCLE_FRAG
varying vec2 vUv;
uniform vec3 color;
float remap(float v, float inMin, float inMax, float outMin, float outMax) {
float t = (v - inMin) / (inMax - inMin);
return outMin + (outMax - outMin) * t;
}
void main() {
float dis = distance(vUv, vec2(0.5));
float thickness = 0.05;
gl_FragColor = vec4(0.0);
if(dis <= 0.35 && dis >= 0.35 - thickness) {
gl_FragColor = vec4(color, 0.8);
} else {
// emissive
float scalar = 0.0;
if(dis >= 0.35) {
scalar = clamp(0.5 - dis, 0.0, 0.15);
scalar = remap(scalar, 0.0, 0.15, 0.0, 1.0);
} else {
scalar = clamp(0.35 - dis, 0.0, 0.5);
scalar = remap(scalar, 0.0, 0.35, 1.0, 0.0);
}
scalar = clamp(scalar * scalar * scalar, 0.0, 1.0);
gl_FragColor = vec4(color, scalar);
}
}`,
})
}
function createCircle(material: THREE.ShaderMaterial, position: THREE.Vector3) {
const geometry = new THREE.PlaneGeometry(0.6, 0.6)
const mesh = new THREE.Mesh(geometry, material)
mesh.position.copy(position)
return mesh
}
function main() {
const canvas = document.querySelector<HTMLCanvasElement>('#about_scene')
if (!canvas) return console.error('No canvas')
let isUpdating = true
const canvasSize = new THREE.Vector2(
canvas.getBoundingClientRect().width,
canvas.getBoundingClientRect().height,
)
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
canvas,
})
renderer.setPixelRatio(devicePixelRatio)
renderer.setSize(canvasSize.x, canvasSize.y)
const deltaClock = new THREE.Clock()
const elapseClock = new THREE.Clock()
deltaClock.start()
elapseClock.start()
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(30, canvasSize.x / canvasSize.y, 1, 3000)
camera.fov *= 0.7
camera.position.set(-10, 5, 30)
camera.lookAt(0, 0, 0)
const ambientLight = new THREE.AmbientLight(0xffffff)
scene.add(ambientLight)
const dirLight = new THREE.DirectionalLight(0xffffff, 4.0)
dirLight.position.set(-30, 30, 28)
scene.add(dirLight)
scene.add(createTip(dirLight.position, 0xffff00))
scene.add(createTip(camera.position))
const accentColor =
getComputedStyle(document.documentElement).getPropertyValue('--color-brand').trim() || '#4444ff'
const waterMaterial = createWaterMaterial()
waterMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
isDarkMode() ? 0.6 : 2.4,
)
// .multiplyScalar(0.6)
// .multiplyScalar(2.4)
scene.add(createWater(waterMaterial, new THREE.Vector3(0, -6.5, 4)))
scene.add(createWater(waterMaterial, new THREE.Vector3(2, -8, -10)))
scene.add(createWater(waterMaterial, new THREE.Vector3(16, -8, -26)))
// 静止的下界之星(替换原动态美西螈)
const starTexture = new THREE.TextureLoader().load('/models/netherstar.png')
starTexture.colorSpace = THREE.SRGBColorSpace
const starMaterial = new THREE.SpriteMaterial({ map: starTexture, transparent: true })
const starSprite = new THREE.Sprite(starMaterial)
starSprite.scale.set(8, 8, 1)
starSprite.position.set(0, -2.5, 0)
scene.add(starSprite)
let updateGLTF = (_deltaTime: number, _elapsedTime: number) => {}
const circleMaterial = createCircleMaterial()
circleMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
isDarkMode() ? 1.2 : 3,
)
// .multiplyScalar(1.2)
// .multiplyScalar(3)
let circleMeshList: THREE.Mesh[] = []
let nextCircleCreateTime = 0.0
function updateCircle(deltaTime: number, elapsedTime: number) {
circleMeshList = circleMeshList.filter((m) => {
m.position.y += deltaTime * 2.0
if (m.position.y >= 32) {
scene.remove(m)
return false
}
return true
})
if (elapsedTime >= nextCircleCreateTime) {
nextCircleCreateTime = elapsedTime + Math.random() * 0.8
const circle = createCircle(
circleMaterial,
new THREE.Vector3(Math.random() * 64 - 32 - 12, -20, Math.random() * 6 + 1),
)
scene.add(circle)
circleMeshList.push(circle)
}
}
function animate(_time: number) {
if (isUpdating === false) return
requestAnimationFrame(animate)
const deltaTime = deltaClock.getDelta()
const elapsedTime = elapseClock.getElapsedTime()
updateGLTF(deltaTime, elapsedTime)
waterMaterial.uniforms.time.value = elapsedTime
updateCircle(deltaTime, elapsedTime)
renderer.render(scene, camera)
}
animate(Date.now())
const originCameraPosition = camera.position.clone()
function onMouseMove(event: MouseEvent) {
const mouseXOffsetRatio = ((event.clientX - innerWidth / 2) / innerWidth) * 2
const mouseYOffsetRatio = ((event.clientY - innerHeight / 2) / innerHeight) * 2
const newPosition = new THREE.Vector3(
originCameraPosition.x + mouseXOffsetRatio,
originCameraPosition.y + mouseYOffsetRatio * 0.5,
originCameraPosition.z,
)
camera.position.copy(newPosition)
}
function updateSize() {
if (!isUpdating) return
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const w = rect.width
const h = rect.height
if (w > 0 && h > 0) {
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
}
}
const resizeObserver = new ResizeObserver(updateSize)
resizeObserver.observe(canvas)
addEventListener('mousemove', onMouseMove)
onScopeDispose(() => {
isUpdating = false
removeEventListener('mousemove', onMouseMove)
resizeObserver.disconnect()
deltaClock.stop()
elapseClock.stop()
renderer.dispose()
})
}
onMounted(main)
</script> </script>
<style>
#about_scene { <style scoped>
background: linear-gradient( .about-scene {
to bottom, display: block;
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%), width: 100%;
#00000000 40% height: 100%;
); background: #0c1729;
} }
</style> </style>
@@ -0,0 +1,292 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, h, nextTick, Suspense } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
} from '../../composables/skin-site-session.ts'
import { useNetworkStatus } from '../../composables/useNetworkStatus.ts'
// Exercise the real component's setup and reactive account list. Only platform,
// navigation, and visual dependencies are replaced; auth/session logic stays real.
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': `export const CopyIcon={}, LogInIcon={}, PlusIcon={}, RadioButtonCheckedIcon={}, RadioButtonIcon={}, RefreshCwIcon={}, SpinnerIcon={}, TrashIcon={}`,
'@modrinth/ui': `export const Accordion={}, Avatar={}, ButtonStyled={}; export const defineMessages=x=>x; export const injectNotificationManager=()=>({handleError:()=>{}}); export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})`,
'@tanstack/vue-query': `export const useQueryClient=()=>({refetchQueries:async()=>{}})`,
'@tauri-apps/api/event': `export const listen=async()=>()=>{}`,
'vue-router': `export const useRoute=()=>({fullPath:'/'}); export const useRouter=()=>({push:async()=>{}})`,
'@/assets/netherstar.png': `export default 'logo'`,
'@/components/ui/MinecraftLoginModal.vue': `export default {}`,
'@/helpers/events': `export const process_listener=async()=>()=>{}`,
'@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`,
'@/helpers/skins': `export const get_available_skins=async()=>[]`,
'@/store/error.js': `export const handleSevereError=()=>{}`,
'@/store/state': `export const useTheming=()=>({homeLayout:'standard'})`,
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs) {
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
}
if (specifier.startsWith('@/')) {
const file = new URL(`../../${specifier.slice(2)}`, import.meta.url)
if (!/\.(ts|js)$/.test(file.pathname))
file.pathname += specifier === '@/helpers/auth' ? '.js' : '.ts'
return nextResolve(file.href, context)
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(compileScript(descriptor, { id: 'accounts-card-test' }).content),
shortCircuit: true,
}
},
})
const { default: AccountsCard } = await import(componentUrl)
hooks.deregister()
type TestNode = Record<string, unknown>
type TestAccount = {
account_type: 'microsoft' | 'yggdrasil'
profile: { id: string; name: string }
yggdrasil?: { api_root: string; server_name: string; login: string }
}
type CardState = {
accounts: TestAccount[]
defaultUser: string | undefined
selectedAccount: TestAccount | undefined
setAccount: (account: TestAccount) => Promise<void>
getAccountAvatarUrl: (account: TestAccount) => string | undefined
}
const renderer = createRenderer<TestNode, TestNode>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
const firstId = '12345678-1234-1234-1234-123456789abc'
const secondId = '22345678-1234-1234-1234-123456789abc'
async function fixture(hasOfficial = true) {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
const stored: TestAccount[] = hasOfficial
? [{ account_type: 'microsoft', profile: { id: 'official', name: 'Official' } }]
: []
let selected: string | undefined = hasOfficial ? 'official' : undefined
let rejectLogin = false
let loginCalls = 0
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request')
message({
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'fixture.jwt',
})
if (data.type === 'starlight-skin-players-request')
message({
type: 'starlight-skin-players-result',
requestId: data.requestId,
ok: true,
players: [
{ uuid: firstId, name: 'First', isMojang: false, skinState: 'empty' },
{ uuid: secondId, name: 'Second', isMojang: false, skinState: 'empty' },
],
})
},
} as unknown as Window
function message(data: unknown) {
receiveSkinSiteMessage({ origin: SKIN_SITE_ORIGIN, source: frame, data } as MessageEvent, frame)
}
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
useNetworkStatus().refreshBrowserOffline()
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
if (command === 'plugin:auth|get_default_user') return selected
if (command === 'plugin:auth|get_users') return stored
if (command === 'plugin:auth|login_skin_site_player') {
loginCalls++
assert.equal(args.userId, 'owner')
assert.equal(args.token, 'fixture.jwt')
assert.ok(typeof args.playerId === 'string')
if (rejectLogin) throw new Error('Login failed')
stored.push({
profile: { id: args.playerId, name: args.playerId === firstId ? 'First' : 'Second' },
account_type: 'yggdrasil',
yggdrasil: {
api_root: `${SKIN_SITE_ORIGIN}/yggdrasil`,
server_name: 'StarLight',
login: 'owner',
},
})
return stored[stored.length - 1]
}
if (command === 'plugin:auth|set_default_user') {
assert.ok(typeof args.user === 'string')
assert.ok(stored.some((account) => account.profile.id === args.user))
selected = args.user
return
}
throw new Error(`Unexpected command: ${command}`)
},
},
},
})
resetSkinSiteSession()
setSkinSiteFrame(frame)
const node = h({ ...AccountsCard, render: () => null })
const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) })
app.mount({})
await flush()
return {
state: (node.component! as unknown as { setupState: CardState }).setupState,
login() {
message({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'owner', username: 'Owner' },
})
},
signOut() {
message({ type: 'starlight-skin-session', status: 'signed-out' })
},
failLogin(value: boolean) {
rejectLogin = value
},
get selected() {
return selected
},
get loginCalls() {
return loginCalls
},
close() {
app.unmount()
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
else Reflect.deleteProperty(globalThis, 'navigator')
},
}
}
test('sidebar offers every skin-site player before any game authentication or launch', async () => {
const view = await fixture(false)
try {
view.failLogin(true)
view.login()
await flush()
assert.deepEqual(
view.state.accounts.map((account) => account.profile.id),
[firstId, secondId],
)
assert.equal(view.loginCalls, 0)
assert.equal(view.selected, undefined)
} finally {
view.close()
}
})
test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => {
const view = await fixture()
try {
view.login()
await flush()
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === secondId)!,
)
assert.equal(view.selected, secondId)
assert.equal(view.state.selectedAccount?.profile.id, secondId)
assert.equal(view.state.accounts.length, 3)
assert.equal(view.loginCalls, 1)
} finally {
view.close()
}
})
test('failed player authentication preserves the default and allows retry', async () => {
const view = await fixture()
try {
view.login()
await flush()
view.failLogin(true)
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === secondId)!,
)
assert.equal(view.selected, 'official')
assert.equal(view.state.defaultUser, 'official')
view.failLogin(false)
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === secondId)!,
)
assert.equal(view.selected, secondId)
} finally {
view.close()
}
})
test('signing out removes unregistered choices while keeping saved accounts', async () => {
const view = await fixture()
try {
view.login()
await flush()
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === firstId)!,
)
view.signOut()
await flush()
assert.deepEqual(
view.state.accounts.map((account) => account.profile.id),
[firstId, 'official'],
)
assert.equal(view.selected, firstId)
view.login()
await flush()
assert.equal(view.state.accounts.length, 3)
assert.equal(view.loginCalls, 1)
} finally {
view.close()
}
})
test('a new player without a skin can render the shared empty avatar', async () => {
const view = await fixture(false)
try {
view.login()
await flush()
assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined)
} finally {
view.close()
}
})
@@ -1,10 +1,106 @@
<template> <template>
<div v-if="skinSiteUser" class="flex items-center gap-3 mt-2 p-3 rounded-xl bg-button-bg">
<Avatar :src="axolotlLogo" size="36px" />
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-contrast">{{ skinSiteUser.username }}</span>
<span class="text-secondary text-xs">{{ formatMessage(messages.skinSiteSignedIn) }}</span>
</div>
</div>
<p v-else-if="skinSiteStatus === 'checking'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteChecking) }}
</p>
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteSyncError) }}
</p>
<div
v-if="skinSiteUser"
class="mt-2 overflow-hidden rounded-xl border border-solid border-surface-5 bg-button-bg"
>
<div class="flex items-center justify-between gap-3 px-3 py-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.skinSitePlayers, { count: skinSitePlayers.length }) }}
</span>
<button
v-if="skinSitePlayersStatus === 'error'"
type="button"
class="button-base flex cursor-pointer items-center gap-1 border-0 bg-transparent p-1 text-xs text-secondary hover:text-brand"
@click="refreshSkinSitePlayers()"
>
<RefreshCwIcon class="h-4 w-4" />
{{ formatMessage(messages.retrySkinSitePlayers) }}
</button>
<SpinnerIcon
v-else-if="skinSitePlayersStatus === 'checking'"
class="h-4 w-4 animate-spin text-secondary"
/>
</div>
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
<button
v-for="player in skinSitePlayers"
:key="player.uuid"
type="button"
class="flex w-full cursor-pointer items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-button-hover"
:class="selectedSkinSitePlayerId === player.uuid ? 'bg-button-hover' : 'bg-transparent'"
:aria-pressed="selectedSkinSitePlayerId === player.uuid"
@click="setSkinSitePlayer(player.uuid)"
>
<Avatar
v-if="player.skinState === 'ready' && player.headDataUrl"
:src="player.headDataUrl"
size="32px"
pixelated
:unframed-natural-width="36"
/>
<div
v-else
class="h-8 w-8 shrink-0 rounded-sm border-2 border-dashed border-secondary/45 bg-surface-5/50 text-secondary"
style="
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-position:
6px 8px,
22px 8px,
8px 20px;
background-repeat: no-repeat;
background-size:
4px 4px,
4px 4px,
16px 2px;
"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-sm text-primary">{{ player.name }}</span>
</button>
</div>
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
{{ formatMessage(messages.noSkinSitePlayers) }}
</p>
<p v-else-if="skinSitePlayersStatus === 'error'" class="m-0 px-3 pb-3 text-sm text-secondary">
{{ formatMessage(messages.skinSitePlayersError) }}
</p>
</div>
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<LogInIcon />
{{ formatMessage(messages.signInToStarlight) }}
</button>
</ButtonStyled>
<div <div
v-if="offline" v-if="offline"
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2" class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
> >
<span class="font-semibold text-contrast">{{ formatMessage(messages.offlineMode) }}</span> <span class="font-semibold text-contrast">{{ formatMessage(messages.offlineMode) }}</span>
<span class="text-sm text-secondary">{{ formatMessage(messages.offlineModeDescription) }}</span> <span class="text-sm text-secondary">
{{
formatMessage(
browserOffline
? messages.offlineModeNoInternetDescription
: messages.offlineModeServerUnavailableDescription,
)
}}
</span>
<ButtonStyled> <ButtonStyled>
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()"> <button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" /> <SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
@@ -17,18 +113,18 @@
v-if="accounts.length === 0" v-if="accounts.length === 0"
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2" class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
> >
<span>{{ formatMessage(messages.notSignedIn) }}</span> <span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled v-if="!offline" color="brand"> <ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
<button color="primary" :disabled="loginDisabled" @click="login()"> <button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<LogInIcon v-if="!loginDisabled" /> <LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" /> <SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToMinecraft) }} {{ formatMessage(messages.signInToStarlight) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-if="!offline"> <ButtonStyled v-if="!offline">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()"> <button :disabled="loginDisabled" @click="login()">
<PlusIcon /> <PlusIcon />
{{ formatMessage(messages.addThirdPartyAccount) }} {{ formatMessage(messages.addMicrosoftAccount) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -68,6 +164,7 @@
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center"> <div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
<button <button
class="flex items-center flex-shrink flex-grow overflow-clip gap-2 p-2 border-0 bg-transparent cursor-pointer button-base min-w-0" class="flex items-center flex-shrink flex-grow overflow-clip gap-2 p-2 border-0 bg-transparent cursor-pointer button-base min-w-0"
:disabled="accountSelectionPending"
@click="setAccount(account)" @click="setAccount(account)"
> >
<RadioButtonCheckedIcon <RadioButtonCheckedIcon
@@ -125,6 +222,7 @@
<CopyIcon /> <CopyIcon />
</button> </button>
<button <button
v-if="!account.skin_site_user"
v-tooltip="formatMessage(messages.removeAccount)" v-tooltip="formatMessage(messages.removeAccount)"
type="button" type="button"
class="button-base border-0 bg-transparent p-1.5 cursor-pointer text-secondary hover:text-red" class="button-base border-0 bg-transparent p-1.5 cursor-pointer text-secondary hover:text-red"
@@ -142,109 +240,10 @@
{{ formatMessage(messages.addMicrosoftAccount) }} {{ formatMessage(messages.addMicrosoftAccount) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addThirdPartyAccount) }}
</button>
</ButtonStyled>
</div> </div>
</div> </div>
</Accordion> </Accordion>
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" /> <MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
<ModalWrapper ref="yggdrasilAccountModal" :header="formatMessage(messages.thirdPartyModalTitle)">
<div class="flex min-w-[24rem] flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.thirdPartyModalDescription) }}</p>
<div v-if="savedYggdrasilLogins.length > 0" class="flex flex-col gap-2">
<span class="font-semibold">{{ formatMessage(messages.savedLogins) }}</span>
<div
v-for="savedLogin in savedYggdrasilLogins"
:key="`${savedLogin.api_root}:${savedLogin.login}`"
class="flex items-center gap-1 rounded-xl bg-surface-3 p-1"
>
<button
class="flex min-w-0 flex-grow flex-col items-start border-0 bg-transparent px-3 py-2 text-left cursor-pointer"
:disabled="loginDisabled"
@click="selectSavedYggdrasilLogin(savedLogin)"
>
<span class="w-full truncate font-semibold text-primary">{{ savedLogin.login }}</span>
<span class="w-full truncate text-xs text-secondary">{{ savedLogin.api_root }}</span>
</button>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button
v-tooltip="formatMessage(messages.removeSavedLogin)"
:disabled="loginDisabled"
@click="removeSavedYggdrasilLogin(savedLogin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.apiRootLabel) }}
<StyledInput
v-model="yggdrasilApiRoot"
:disabled="true"
readonly
:placeholder="formatMessage(messages.apiRootPlaceholder)"
inputmode="url"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.accountLabel) }}
<StyledInput
v-model="yggdrasilLogin"
:disabled="loginDisabled"
:placeholder="formatMessage(messages.accountPlaceholder)"
autocomplete="username"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.passwordLabel) }}
<StyledInput
v-model="yggdrasilPassword"
type="password"
:disabled="loginDisabled"
autocomplete="current-password"
@keyup.enter="addYggdrasilAccount()"
/>
</label>
<Checkbox
v-model="rememberYggdrasilPassword"
:disabled="loginDisabled"
:label="formatMessage(messages.rememberPassword)"
/>
<div class="input-group push-right">
<ButtonStyled>
<button :disabled="loginDisabled" @click="yggdrasilAccountModal?.hide()">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loginDisabled || !yggdrasilFormValid" @click="addYggdrasilAccount()">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<LogInIcon v-else />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="yggdrasilProfileModal" :header="formatMessage(messages.selectProfileTitle)">
<div class="flex min-w-[22rem] flex-col gap-2">
<p class="m-0 mb-2 text-secondary">{{ formatMessage(messages.selectProfileDescription) }}</p>
<ButtonStyled v-for="profile in pendingYggdrasilProfiles" :key="profile.id" class="w-full">
<button :disabled="loginDisabled" @click="selectYggdrasilProfile(profile.id)">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<RadioButtonIcon v-else />
{{ profile.name }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -262,49 +261,59 @@ import {
Accordion, Accordion,
Avatar, Avatar,
ButtonStyled, ButtonStyled,
commonMessages,
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
StyledInput,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import axolotlLogo from '@/assets/netherstar.png' import axolotlLogo from '@/assets/netherstar.png'
import steveSkinTexture from '@/assets/skins/steve.png?inline'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue' import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import {
openSkinSiteLogin,
requestSkinSitePlayers,
selectedSkinSitePlayerId,
selectSkinSitePlayer,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { compareMinecraftAccounts } from '@/helpers/accounts' import { compareMinecraftAccounts } from '@/helpers/accounts'
import { trackEvent } from '@/helpers/analytics'
import { import {
begin_yggdrasil_login,
delete_yggdrasil_password,
finish_yggdrasil_login,
get_default_user, get_default_user,
get_yggdrasil_password,
list_yggdrasil_saved_logins,
login as loginToMinecraft, login as loginToMinecraft,
remove_user, remove_user,
set_default_user, set_default_user,
set_yggdrasil_password,
users, users,
} from '@/helpers/auth' } from '@/helpers/auth'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { authenticateInstancePlayer } from '@/helpers/instance-player'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts' import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins' import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins' import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const { offline, refreshBrowserOffline } = useNetworkStatus() const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const route = useRoute() const route = useRoute()
const router = useRouter()
const themeStore = useTheming()
async function goToSkinSiteLogin() {
openSkinSiteLogin()
// The login action must reveal the iframe even if Minimal Home was selected.
themeStore.homeLayout = 'standard'
await router.push('/').catch(handleError)
}
const refreshingNetwork = ref(false) const refreshingNetwork = ref(false)
/** /**
@@ -346,25 +355,45 @@ type MinecraftCredential = {
server_name: string server_name: string
login: string login: string
} }
skin_site_user?: string
head?: string
} }
type YggdrasilProfile = {
id: string
name: string
}
type SavedYggdrasilLogin = {
api_root: string
login: string
}
type YggdrasilLoginResult =
| { status: 'complete'; credentials: MinecraftCredential }
| { status: 'select_profile'; flow_id: string; profiles: YggdrasilProfile[] }
const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil' const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
const accounts: Ref<MinecraftCredential[]> = ref([]) const storedAccounts: Ref<MinecraftCredential[]> = ref([])
const accounts = computed(() => {
// A site's player list is already authoritative for available choices.
// Game credentials are created only when the user selects a player.
const byId = new Map(
storedAccounts.value.map((account) => [
account.profile.id.replaceAll('-', '').toLowerCase(),
account,
]),
)
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
for (const player of skinSitePlayers.value) {
if (player.isMojang) continue
const id = player.uuid.replaceAll('-', '').toLowerCase()
const stored = byId.get(id)
if (stored && stored.account_type !== 'yggdrasil') continue
byId.set(id, {
...stored,
account_type: 'yggdrasil',
profile: { ...stored?.profile, id: stored?.profile.id ?? player.uuid, name: player.name },
yggdrasil: {
api_root: STARLIGHT_YGGDRASIL_API_ROOT,
server_name: 'StarLight',
login: skinSiteUser.value.uuid,
},
skin_site_user: skinSiteUser.value.uuid,
head: player.headDataUrl,
})
}
}
return [...byId.values()].sort(compareMinecraftAccounts)
})
const accountSelectionPending = ref(false)
const loginDisabled = ref(false) const loginDisabled = ref(false)
const defaultUser = ref<string | undefined>() const defaultUser = ref<string | undefined>()
const equippedSkin = ref<Skin | null>(null) const equippedSkin = ref<Skin | null>(null)
@@ -376,34 +405,13 @@ let refreshGeneration = 0
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
let defaultUserUpdateQueue = Promise.resolve() let defaultUserUpdateQueue = Promise.resolve()
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null) const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null) async function refreshSkinSitePlayers() {
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null) await requestSkinSitePlayers().catch(() => {})
const yggdrasilApiRoot = ref(STARLIGHT_YGGDRASIL_API_ROOT)
const yggdrasilLogin = ref('')
const yggdrasilPassword = ref('')
const rememberYggdrasilPassword = ref(true)
const savedYggdrasilLogins = ref<SavedYggdrasilLogin[]>([])
const pendingYggdrasilFlowId = ref<string | undefined>()
const pendingYggdrasilProfiles = ref<YggdrasilProfile[]>([])
const yggdrasilFormValid = computed(
() =>
yggdrasilApiRoot.value.trim().length > 0 &&
yggdrasilLogin.value.trim().length > 0 &&
yggdrasilPassword.value.length > 0,
)
function createSkinHeadDataUrl(textureUrl: string) {
const escapedTextureUrl = textureUrl
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges"><image href="${escapedTextureUrl}" x="-8" y="-8" width="64" height="64" style="image-rendering:pixelated"/><image href="${escapedTextureUrl}" x="-40" y="-8" width="64" height="64" style="image-rendering:pixelated"/></svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
} }
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture) function setSkinSitePlayer(playerId: string) {
selectSkinSitePlayer(playerId)
}
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000 const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
@@ -418,7 +426,7 @@ function hasResolvedAccountHead(account: MinecraftCredential) {
} }
function hasMissingAccountHeads() { function hasMissingAccountHeads() {
return accounts.value.some( return storedAccounts.value.some(
(account) => account.account_type !== 'offline' && !hasResolvedAccountHead(account), (account) => account.account_type !== 'offline' && !hasResolvedAccountHead(account),
) )
} }
@@ -452,7 +460,7 @@ async function refreshValues(headRefreshAttempt = 0) {
defaultUser.value = selectedUser defaultUser.value = selectedUser
if (offline.value && selectedUser) { if (offline.value && selectedUser) {
await persistDefaultUser(selectedUser) await persistDefaultUser(selectedUser).catch(handleError)
if (generation !== refreshGeneration) return if (generation !== refreshGeneration) return
} }
const userList = await users(offline.value).catch(handleError) const userList = await users(offline.value).catch(handleError)
@@ -461,7 +469,7 @@ async function refreshValues(headRefreshAttempt = 0) {
// MinecraftCredential but the TS types from the Tauri IPC bridge do not // MinecraftCredential but the TS types from the Tauri IPC bridge do not
// carry this refinement. The double cast is deliberate — the shape is // carry this refinement. The double cast is deliberate — the shape is
// correct and verified at runtime by the backend. // correct and verified at runtime by the backend.
accounts.value = Array.isArray(userList) storedAccounts.value = Array.isArray(userList)
? [...(userList as unknown as MinecraftCredential[])].filter( ? [...(userList as unknown as MinecraftCredential[])].filter(
(account) => (account) =>
account.account_type === 'microsoft' || account.account_type === 'microsoft' ||
@@ -469,8 +477,7 @@ async function refreshValues(headRefreshAttempt = 0) {
account.yggdrasil?.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT), account.yggdrasil?.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT),
) )
: [] : []
accounts.value.sort(compareMinecraftAccounts) await renderAccountHeads(storedAccounts.value, generation)
await renderAccountHeads(accounts.value, generation)
if (generation !== refreshGeneration) return if (generation !== refreshGeneration) return
try { try {
const skins = await get_available_skins() const skins = await get_available_skins()
@@ -608,6 +615,7 @@ async function renderAccountHeads(accountList: MinecraftCredential[], generation
const avatarUrl = computed(() => { const avatarUrl = computed(() => {
if (selectedAccount.value) { if (selectedAccount.value) {
if (selectedAccount.value.head) return selectedAccount.value.head
const cachedHeadUrl = accountHeadUrlCache.value.get(selectedAccount.value.profile.id) const cachedHeadUrl = accountHeadUrlCache.value.get(selectedAccount.value.profile.id)
if (cachedHeadUrl) return cachedHeadUrl if (cachedHeadUrl) return cachedHeadUrl
} }
@@ -617,10 +625,11 @@ const avatarUrl = computed(() => {
return cachedUrl return cachedUrl
} }
} }
return selectedAccount.value ? defaultSteveHeadUrl : axolotlLogo return selectedAccount.value ? undefined : axolotlLogo
}) })
function getAccountAvatarUrl(account: MinecraftCredential) { function getAccountAvatarUrl(account: MinecraftCredential) {
if (account.head) return account.head
const cachedHeadUrl = accountHeadUrlCache.value.get(account.profile.id) const cachedHeadUrl = accountHeadUrlCache.value.get(account.profile.id)
if (cachedHeadUrl) { if (cachedHeadUrl) {
return cachedHeadUrl return cachedHeadUrl
@@ -634,29 +643,54 @@ function getAccountAvatarUrl(account: MinecraftCredential) {
return cachedUrl return cachedUrl
} }
} }
return defaultSteveHeadUrl return undefined
} }
function persistDefaultUser(userId: string) { function persistDefaultUser(userId: string) {
const update = defaultUserUpdateQueue.then(async () => { const update = defaultUserUpdateQueue.then(async () => {
await set_default_user(userId).catch(handleError) await set_default_user(userId)
}) })
defaultUserUpdateQueue = update.catch(() => {}) defaultUserUpdateQueue = update.catch(() => {})
return update return update
} }
async function setAccount(account: MinecraftCredential) { async function setAccount(account: MinecraftCredential) {
if (accountSelectionPending.value) return
accountSelectionPending.value = true
const userId = account.profile.id const userId = account.profile.id
refreshGeneration += 1 refreshGeneration += 1
defaultUser.value = userId try {
equippedSkin.value = null if (account.skin_site_user) {
await authenticateInstancePlayer({
await persistDefaultUser(userId) id: userId,
if (defaultUser.value !== userId) return name: account.profile.name,
await refreshValues() account_type: account.account_type,
if (defaultUser.value === userId) notifyAccountChange() skin_site_user: account.skin_site_user,
})
}
await persistDefaultUser(userId)
selectSkinSitePlayer(null)
defaultUser.value = userId
equippedSkin.value = null
notifyAccountChange()
await refreshValues()
} catch (error) {
handleError(error as Error)
} finally {
accountSelectionPending.value = false
}
} }
watch(
[skinSitePlayers, defaultUser, skinSiteUser],
([availablePlayers, selectedLocalUser]) => {
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
selectSkinSitePlayer(availablePlayers[0].uuid)
}
},
{ immediate: true },
)
async function login() { async function login() {
if (offline.value) return if (offline.value) return
loginDisabled.value = true loginDisabled.value = true
@@ -678,7 +712,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
loginDisabled.value = true loginDisabled.value = true
try { try {
await setAccount(account) await setAccount(account)
trackEvent('AccountLogIn')
} catch (error) { } catch (error) {
handleSevereError(error) handleSevereError(error)
} finally { } finally {
@@ -686,187 +719,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
} }
} }
async function showYggdrasilAccountModal() {
yggdrasilApiRoot.value = STARLIGHT_YGGDRASIL_API_ROOT
yggdrasilLogin.value = ''
yggdrasilPassword.value = ''
rememberYggdrasilPassword.value = true
pendingYggdrasilFlowId.value = undefined
pendingYggdrasilProfiles.value = []
await loadSavedYggdrasilLogins()
yggdrasilAccountModal.value?.show()
}
async function loadSavedYggdrasilLogins() {
const storedLogins = await list_yggdrasil_saved_logins().catch(handleError)
const savedLogins: SavedYggdrasilLogin[] = Array.isArray(storedLogins)
? [...storedLogins].filter(
(savedLogin) => savedLogin.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT,
)
: []
const savedLoginKeys = new Set(
savedLogins.map((savedLogin) => `${savedLogin.api_root}\n${savedLogin.login}`),
)
for (const account of accounts.value) {
if (
!account.yggdrasil ||
account.yggdrasil.api_root.replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT
)
continue
const savedLogin = {
api_root: account.yggdrasil.api_root,
login: account.yggdrasil.login,
}
const key = `${savedLogin.api_root}\n${savedLogin.login}`
if (savedLoginKeys.has(key)) continue
try {
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
if (!password) continue
await set_yggdrasil_password(savedLogin.api_root, savedLogin.login, password)
savedLogins.push(savedLogin)
savedLoginKeys.add(key)
} catch {
continue
}
}
savedYggdrasilLogins.value = savedLogins.sort((left, right) =>
left.login.localeCompare(right.login),
)
}
async function selectSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
if (loginDisabled.value) return
loginDisabled.value = true
try {
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
if (!password) {
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
await loadSavedYggdrasilLogins()
return
}
yggdrasilApiRoot.value = savedLogin.api_root
yggdrasilLogin.value = savedLogin.login
yggdrasilPassword.value = password
rememberYggdrasilPassword.value = true
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function removeSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
if (loginDisabled.value) return
loginDisabled.value = true
try {
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
savedYggdrasilLogins.value = savedYggdrasilLogins.value.filter(
(entry) => entry.api_root !== savedLogin.api_root || entry.login !== savedLogin.login,
)
if (
yggdrasilApiRoot.value === savedLogin.api_root &&
yggdrasilLogin.value === savedLogin.login
) {
yggdrasilPassword.value = ''
rememberYggdrasilPassword.value = false
}
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function loadRememberedYggdrasilPassword() {
if (
!rememberYggdrasilPassword.value ||
!yggdrasilApiRoot.value.trim() ||
!yggdrasilLogin.value.trim() ||
yggdrasilPassword.value
)
return
try {
const password = await get_yggdrasil_password(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
)
if (password) yggdrasilPassword.value = password
} catch {
return
}
}
async function persistYggdrasilPasswordPreference() {
try {
if (rememberYggdrasilPassword.value) {
await set_yggdrasil_password(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
yggdrasilPassword.value,
)
} else {
await delete_yggdrasil_password(yggdrasilApiRoot.value.trim(), yggdrasilLogin.value.trim())
}
} catch (error) {
handleError(error as Error)
}
}
async function addYggdrasilAccount() {
if (!yggdrasilFormValid.value || loginDisabled.value) return
if (yggdrasilApiRoot.value.trim().replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT) return
loginDisabled.value = true
try {
const result = (await begin_yggdrasil_login(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
yggdrasilPassword.value,
)) as YggdrasilLoginResult
if (result.status === 'complete') {
await persistYggdrasilPasswordPreference()
yggdrasilAccountModal.value?.hide()
await setAccount(result.credentials)
trackEvent('YggdrasilAccountAdd')
} else {
pendingYggdrasilFlowId.value = result.flow_id
pendingYggdrasilProfiles.value = result.profiles
yggdrasilAccountModal.value?.hide()
yggdrasilProfileModal.value?.show()
}
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function selectYggdrasilProfile(profileId: string) {
if (!pendingYggdrasilFlowId.value || loginDisabled.value) return
loginDisabled.value = true
try {
const account = (await finish_yggdrasil_login(
pendingYggdrasilFlowId.value,
profileId,
)) as MinecraftCredential
await persistYggdrasilPasswordPreference()
yggdrasilProfileModal.value?.hide()
await setAccount(account)
trackEvent('YggdrasilAccountAdd')
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function logout(account: MinecraftCredential) { async function logout(account: MinecraftCredential) {
await remove_user(account.profile.id).catch(handleError) await remove_user(account.profile.id).catch(handleError)
await refreshValues() await refreshValues()
@@ -875,7 +727,6 @@ async function logout(account: MinecraftCredential) {
} else { } else {
notifyAccountChange() notifyAccountChange()
} }
trackEvent('AccountLogOut')
} }
async function copyAccountUuid(account: MinecraftCredential) { async function copyAccountUuid(account: MinecraftCredential) {
@@ -886,7 +737,7 @@ async function copyAccountUuid(account: MinecraftCredential) {
} }
} }
const unlisten = await process_listener(async (e) => { const unlisten = await process_listener(async (e: { event: string }) => {
if (e.event === 'launched') { if (e.event === 'launched') {
await refreshValues() await refreshValues()
} }
@@ -902,14 +753,47 @@ onUnmounted(() => {
}) })
const messages = defineMessages({ const messages = defineMessages({
skinSiteSignedIn: {
id: 'minecraft-account.skin-site.signed-in',
defaultMessage: 'Signed in to StarLight Skin Site',
},
skinSiteChecking: {
id: 'minecraft-account.skin-site.checking',
defaultMessage: 'Checking skin site session…',
},
skinSiteSyncError: {
id: 'minecraft-account.skin-site.sync-error',
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
},
skinSitePlayers: {
id: 'minecraft-account.skin-site.players',
defaultMessage: 'Skin site players ({count})',
},
retrySkinSitePlayers: {
id: 'minecraft-account.skin-site.players.retry',
defaultMessage: 'Retry',
},
noSkinSitePlayers: {
id: 'minecraft-account.skin-site.players.empty',
defaultMessage: 'No player profiles are attached to this skin site account.',
},
skinSitePlayersError: {
id: 'minecraft-account.skin-site.players.error',
defaultMessage: 'Could not refresh the player list. Previously loaded players are kept.',
},
offlineMode: { offlineMode: {
id: 'minecraft-account.offline-mode', id: 'minecraft-account.offline-mode',
defaultMessage: 'Offline mode', defaultMessage: 'Offline mode',
}, },
offlineModeDescription: { offlineModeNoInternetDescription: {
id: 'minecraft-account.offline-mode.description', id: 'minecraft-account.offline-mode.description.no-internet',
defaultMessage: defaultMessage:
'Only offline accounts are available. You can launch fully downloaded instances.', 'It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below.',
},
offlineModeServerUnavailableDescription: {
id: 'minecraft-account.offline-mode.description.server-unavailable',
defaultMessage:
"Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status.",
}, },
refreshNetworkStatus: { refreshNetworkStatus: {
id: 'minecraft-account.offline-mode.refresh', id: 'minecraft-account.offline-mode.refresh',
@@ -921,11 +805,11 @@ const messages = defineMessages({
}, },
addMicrosoftAccount: { addMicrosoftAccount: {
id: 'minecraft-account.add-microsoft-account', id: 'minecraft-account.add-microsoft-account',
defaultMessage: 'Add Microsoft account', defaultMessage: 'Add your own Minecraft account',
}, },
addThirdPartyAccount: { signInToStarlight: {
id: 'minecraft-account.add-third-party-account', id: 'minecraft-account.sign-in-starlight',
defaultMessage: 'Add third-party account', defaultMessage: 'Sign in to StarLight Skin Site',
}, },
thirdPartyAccount: { thirdPartyAccount: {
id: 'minecraft-account.third-party-account', id: 'minecraft-account.third-party-account',
@@ -935,58 +819,6 @@ const messages = defineMessages({
id: 'minecraft-account.third-party-badge', id: 'minecraft-account.third-party-badge',
defaultMessage: 'StarLight skin', defaultMessage: 'StarLight skin',
}, },
thirdPartyModalTitle: {
id: 'minecraft-account.third-party-modal.title',
defaultMessage: 'Sign in with StarLight skin',
},
thirdPartyModalDescription: {
id: 'minecraft-account.third-party-modal.description',
defaultMessage: 'Sign in with your StarLight skin account.',
},
apiRootLabel: {
id: 'minecraft-account.third-party-modal.api-root',
defaultMessage: 'StarLight skin API address',
},
apiRootPlaceholder: {
id: 'minecraft-account.third-party-modal.api-root-placeholder',
defaultMessage: 'https://skin.starlight.cool/yggdrasil',
},
accountLabel: {
id: 'minecraft-account.third-party-modal.account',
defaultMessage: 'Account or email',
},
accountPlaceholder: {
id: 'minecraft-account.third-party-modal.account-placeholder',
defaultMessage: 'Enter your account or email',
},
passwordLabel: {
id: 'minecraft-account.third-party-modal.password',
defaultMessage: 'Password',
},
rememberPassword: {
id: 'minecraft-account.third-party-modal.remember-password',
defaultMessage: 'Save this login on this device',
},
savedLogins: {
id: 'minecraft-account.third-party-modal.saved-logins',
defaultMessage: 'Saved logins',
},
removeSavedLogin: {
id: 'minecraft-account.third-party-modal.remove-saved-login',
defaultMessage: 'Remove saved login',
},
signInButton: {
id: 'minecraft-account.third-party-modal.sign-in',
defaultMessage: 'Sign in',
},
selectProfileTitle: {
id: 'minecraft-account.third-party-profile.title',
defaultMessage: 'Select a profile',
},
selectProfileDescription: {
id: 'minecraft-account.third-party-profile.description',
defaultMessage: 'Choose the Minecraft profile to use with this account.',
},
offlineAccount: { offlineAccount: {
id: 'minecraft-account.offline-account', id: 'minecraft-account.offline-account',
defaultMessage: 'Offline Minecraft account', defaultMessage: 'Offline Minecraft account',
@@ -1069,10 +901,6 @@ const messages = defineMessages({
id: 'minecraft-account.label', id: 'minecraft-account.label',
defaultMessage: 'Minecraft account', defaultMessage: 'Minecraft account',
}, },
signInToMinecraft: {
id: 'minecraft-account.sign-in',
defaultMessage: 'Sign in to Minecraft',
},
loginTrouble: { loginTrouble: {
id: 'minecraft-login.trouble', id: 'minecraft-login.trouble',
defaultMessage: 'Having trouble?', defaultMessage: 'Having trouble?',
@@ -0,0 +1,239 @@
<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;
top: 0;
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: linear-gradient(
90deg,
#f472b6,
#facc15,
#4ade80,
#38bdf8,
#a78bfa,
#f472b6
);
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.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>
@@ -137,6 +137,11 @@
<OnlineIndicatorIcon /> <OnlineIndicatorIcon />
<span class="mr-auto text-contrast flex items-center gap-2"> <span class="mr-auto text-contrast flex items-center gap-2">
{{ process.instance.name }} {{ process.instance.name }}
<span class="text-xs text-secondary">{{
formatMessage(messages.windowStarted, {
time: new Date(process.start_time),
})
}}</span>
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" /> <StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
</span> </span>
</button> </button>
@@ -212,7 +217,6 @@ import { useRoute, useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue' import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications' import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events' import { loading_listener, process_listener } from '@/helpers/events'
import { get_many as getInstances } from '@/helpers/instance' import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process' import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
@@ -308,11 +312,13 @@ const notificationCenterShown = ref(false)
interface RunningProcess { interface RunningProcess {
uuid: string uuid: string
start_time: string
instance_id: string instance_id: string
instance: GameInstance instance: GameInstance
} }
interface LoadingEventPayload { interface LoadingEventPayload {
total?: number | null
event: LoadingBar['bar_type'] event: LoadingBar['bar_type']
loader_uuid: string loader_uuid: string
fraction: number | null fraction: number | null
@@ -320,6 +326,10 @@ interface LoadingEventPayload {
} }
const messages = defineMessages({ const messages = defineMessages({
windowStarted: {
id: 'app.action-bar.window-started',
defaultMessage: 'Started {time, time, medium}',
},
offline: { offline: {
id: 'app.action-bar.offline', id: 'app.action-bar.offline',
defaultMessage: 'Offline', defaultMessage: 'Offline',
@@ -346,7 +356,7 @@ const messages = defineMessages({
}, },
stopInstance: { stopInstance: {
id: 'app.action-bar.stop-instance', id: 'app.action-bar.stop-instance',
defaultMessage: 'Stop instance', defaultMessage: 'Stop window',
}, },
viewLogs: { viewLogs: {
id: 'app.action-bar.view-logs', id: 'app.action-bar.view-logs',
@@ -401,8 +411,9 @@ const refresh = async () => {
const processes = ((await getRunningProcesses().catch((error) => { const processes = ((await getRunningProcesses().catch((error) => {
handleError(error) handleError(error)
return [] return []
})) ?? []) as Array<{ uuid: string; instance_id: string }> })) ?? []) as Array<{ uuid: string; instance_id: string; start_time: string }>
const instanceIds = processes.map((process) => process.instance_id) processes.sort((a, b) => a.start_time.localeCompare(b.start_time))
const instanceIds = [...new Set(processes.map((process) => process.instance_id))]
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => { const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
handleError(error) handleError(error)
return [] return []
@@ -420,9 +431,9 @@ const refresh = async () => {
} }
}) })
.filter((process): process is RunningProcess => process !== null) .filter((process): process is RunningProcess => process !== null)
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) { selectedProcess.value =
selectedProcess.value = currentProcesses.value[0] currentProcesses.value.find((process) => process.uuid === selectedProcess.value?.uuid) ??
} currentProcesses.value[0]
} }
await refresh() await refresh()
@@ -436,12 +447,6 @@ const unlistenProcess = await process_listener(async () => {
const stop = async (process: RunningProcess) => { const stop = async (process: RunningProcess) => {
try { try {
await killProcess(process.uuid).catch(handleError) await killProcess(process.uuid).catch(handleError)
trackEvent('InstanceStop', {
loader: process.instance.loader,
game_version: process.instance.game_version,
source: 'AppBar',
})
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
@@ -635,6 +640,7 @@ function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
return ( return (
loadingBar.bar_type?.type !== 'launcher_update' && loadingBar.bar_type?.type !== 'launcher_update' &&
[ [
'hosted_pack_sync',
'java_download', 'java_download',
'pack_file_download', 'pack_file_download',
'pack_download', 'pack_download',
@@ -661,8 +667,8 @@ function applyLoadingEvent(payload: LoadingEventPayload): boolean {
const loadingBar = formatLoadingBars({ const loadingBar = formatLoadingBars({
loading_bar_uuid: payload.loader_uuid, loading_bar_uuid: payload.loader_uuid,
message: payload.message, message: payload.message,
current: payload.fraction, current: payload.fraction * (payload.total ?? 1),
total: 1, total: payload.total ?? 1,
bar_type: payload.event, bar_type: payload.event,
}) })
if (!isVisibleLoadingBar(loadingBar)) return false if (!isVisibleLoadingBar(loadingBar)) return false
@@ -23,7 +23,6 @@ import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons' import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { AxolotlBrandConfig } from '@/config' import { AxolotlBrandConfig } from '@/config'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js' import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install_existing_instance } from '@/helpers/install' import { install_existing_instance } from '@/helpers/install'
import { cancel_directory_change } from '@/helpers/settings.ts' import { cancel_directory_change } from '@/helpers/settings.ts'
@@ -241,7 +240,6 @@ async function loginMinecraft() {
await set_default_user(loggedIn.profile.id).catch(handleError) await set_default_user(loggedIn.profile.id).catch(handleError)
} }
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
loadingMinecraft.value = false loadingMinecraft.value = false
errorModal.value.hide() errorModal.value.hide()
} catch (err) { } catch (err) {
@@ -11,8 +11,8 @@ import {
StyledInput, StyledInput,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { join } from '@tauri-apps/api/path' import { join } from '@tauri-apps/api/path'
import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs' import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue' import { ref } from 'vue'
@@ -1,12 +1,5 @@
<script setup> <script setup>
import { import { DownloadIcon, GameIcon, PlayIcon, SpinnerIcon, TimerIcon } from '@modrinth/assets'
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, ButtonStyled,
@@ -23,11 +16,11 @@ import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install' import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons' import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js' import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
@@ -37,6 +30,7 @@ const formatRelativeTime = useRelativeTime()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError() const handleMinecraftLaunchError = useMinecraftLaunchError()
const messages = defineMessages({ const messages = defineMessages({
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' }, loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' },
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' }, played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' }, neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
@@ -86,13 +80,8 @@ const isPlaying = computed(() => props.playing ?? internalPlaying.value)
const displayIcon = computed(() => const displayIcon = computed(() =>
getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader), getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader),
) )
const loading = ref(false) const loading = computed(() => isInstanceLaunching(props.instance.id))
const modLoading = computed( const modLoading = computed(() => loading.value || installing.value)
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !isPlaying.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing')) const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed') const installed = computed(() => props.instance.install_stage === 'installed')
@@ -106,41 +95,26 @@ const checkProcess = async () => {
if (props.playing !== undefined) return if (props.playing !== undefined) return
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError) const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
internalPlaying.value = runningProcesses.length > 0 if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
} }
const play = async (e, context) => { const play = async (e) => {
e?.stopPropagation() e?.stopPropagation()
loading.value = true if (loading.value) return
await run(props.instance.id) await run(props.instance.id).catch(async (err) => {
.catch(async (err) => { const handled = await handleMinecraftLaunchError(err, {
const handled = await handleMinecraftLaunchError(err, { instance_id: props.instance.id,
instance_id: props.instance.id, instance_name: props.instance.name,
instance_name: props.instance.name,
})
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
}) })
.finally(() => { if (!handled) handleSevereError(err, { instanceId: props.instance.id })
trackEvent('InstanceStart', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
})
loading.value = false
}
const stop = async (e, context) => {
e?.stopPropagation()
internalPlaying.value = false
await kill(props.instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
}) })
await checkProcess()
}
const stop = async (e) => {
e?.stopPropagation()
await kill(props.instance.id).catch(handleError)
await checkProcess()
} }
const repair = async (e) => { const repair = async (e) => {
@@ -182,16 +156,11 @@ defineExpose({
instance: props.instance, instance: props.instance,
}) })
const currentEvent = ref(null)
const unlisten = const unlisten =
props.playing === undefined props.playing === undefined
? await process_listener((e) => { ? await process_listener((e) => {
if (e.instance_id === props.instance.id) { if (e.instance_id === props.instance.id) {
currentEvent.value = e.event void checkProcess()
if (e.event === 'finished') {
internalPlaying.value = false
}
} }
}) })
: () => undefined : () => undefined
@@ -223,15 +192,7 @@ onUnmounted(() => unlisten())
<span class="line-clamp-2">{{ instance.name }}</span> <span class="line-clamp-2">{{ instance.name }}</span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<ButtonStyled v-if="isPlaying" color="red" circular @mousehover="checkProcess"> <ButtonStyled v-if="modLoading" color="standard" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
@click="(e) => stop(e, 'InstanceCard')"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="formatMessage(messages.loading)" disabled> <button v-tooltip="formatMessage(messages.loading)" disabled>
<SpinnerIcon class="animate-spin" /> <SpinnerIcon class="animate-spin" />
</button> </button>
@@ -241,10 +202,10 @@ onUnmounted(() => unlisten())
v-tooltip=" v-tooltip="
offline && !installed offline && !installed
? formatMessage(messages.offlineInstalledOnly) ? formatMessage(messages.offlineInstalledOnly)
: formatMessage(commonMessages.playButton) : formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
" "
:disabled="offline && !installed" :disabled="offline && !installed"
@click="(e) => play(e, 'InstanceCard')" @click="play"
@mousehover="checkProcess" @mousehover="checkProcess"
> >
<!-- Translate for optical centering --> <!-- Translate for optical centering -->
@@ -301,17 +262,8 @@ onUnmounted(() => unlisten())
/> />
</div> </div>
<div class="absolute bottom-1.5 right-1.5 flex size-12 items-center justify-center"> <div class="absolute bottom-1.5 right-1.5 flex size-12 items-center justify-center">
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled <ButtonStyled
v-else-if="!modLoading && !installing && !installed" v-if="!modLoading && !installing && !installed"
size="large" size="large"
color="brand" color="brand"
circular circular
@@ -335,13 +287,15 @@ onUnmounted(() => unlisten())
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular> <ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular>
<button <button
v-tooltip="formatMessage(commonMessages.playButton)" v-tooltip="
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:class="{ :class="{
'pointer-events-none scale-75 opacity-0': disabled, 'pointer-events-none scale-75 opacity-0': disabled,
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100': 'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
!disabled, !disabled,
}" }"
@click="(e) => play(e, 'InstanceCard')" @click="play"
@mousehover="checkProcess" @mousehover="checkProcess"
> >
<PlayIcon class="translate-x-[1px]" /> <PlayIcon class="translate-x-[1px]" />
@@ -380,19 +334,8 @@ onUnmounted(() => unlisten())
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`" :class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/> />
<div class="absolute inset-0 flex items-center justify-center"> <div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
:class="{ 'scale-100 opacity-100': isPlaying }"
class="transition-all origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<SpinnerIcon <SpinnerIcon
v-else-if="modLoading || installing" v-if="modLoading || installing"
v-tooltip=" v-tooltip="
modLoading modLoading
? formatMessage(messages.loading) ? formatMessage(messages.loading)
@@ -417,7 +360,9 @@ onUnmounted(() => unlisten())
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular> <ButtonStyled v-else size="large" color="brand" circular>
<button <button
v-tooltip="formatMessage(commonMessages.playButton)" v-tooltip="
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`" :class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`"
@click="(e) => play(e, 'InstanceCard')" @click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess" @mousehover="checkProcess"
@@ -59,7 +59,6 @@ import {
import { onUnmounted, ref } from 'vue' import { onUnmounted, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { java_discovery_listener } from '@/helpers/events' import { java_discovery_listener } from '@/helpers/events'
import { find_filtered_jres } from '@/helpers/jre.js' import { find_filtered_jres } from '@/helpers/jre.js'
@@ -131,9 +130,5 @@ const emit = defineEmits(['submit'])
function setJavaInstall(javaInstall) { function setJavaInstall(javaInstall) {
emit('submit', javaInstall) emit('submit', javaInstall)
detectJavaModal.value.hide() detectJavaModal.value.hide()
trackEvent('JavaAutoDetect', {
path: javaInstall.path,
version: javaInstall.version,
})
} }
</script> </script>
@@ -112,7 +112,6 @@ import { computed, ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue' import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest' import useJavaTest from '@/composables/useJavaTest'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js' import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
@@ -196,9 +195,9 @@ const recommendedInstalled = computed(() => {
let hasInitialized = false let hasInitialized = false
async function runTest(path) { async function runTest(path) {
await testJavaInstallation(path, testVersion.value, true) await testJavaInstallation(path, testVersion.value)
if (props.version != null) { if (props.version != null) {
await recommendedJavaTest.testJavaInstallation(path, props.version, false) await recommendedJavaTest.testJavaInstallation(path, props.version)
} }
} }
@@ -212,9 +211,9 @@ watch(
(newPath) => { (newPath) => {
if (newPath) { if (newPath) {
if (!hasInitialized) { if (!hasInitialized) {
testJavaInstallation(newPath, testVersion.value, false) testJavaInstallation(newPath, testVersion.value)
if (props.version != null) { if (props.version != null) {
recommendedJavaTest.testJavaInstallation(newPath, props.version, false) recommendedJavaTest.testJavaInstallation(newPath, props.version)
} }
hasInitialized = true hasInitialized = true
} else { } else {
@@ -242,9 +241,6 @@ async function handleJavaFileInput() {
} }
} }
trackEvent('JavaManualSelect', {
version: props.version,
})
commitSelection(result) commitSelection(result)
} }
@@ -279,7 +275,6 @@ async function reinstallJava() {
} }
} }
trackEvent('JavaReInstall', { path: path, version: props.version })
commitSelection(result) commitSelection(result)
runTest(result.path) runTest(result.path)
} finally { } finally {
@@ -522,7 +522,7 @@ async function copyShareUrl(): Promise<void> {
} }
} }
function _openAIAnalysis(): void { function openAIAnalysis(): void {
if (!lastAnalysis?.combined_log) { if (!lastAnalysis?.combined_log) {
notifyNoLogContent() notifyNoLogContent()
return return
@@ -603,7 +603,7 @@ onUnmounted(() => {
unlistenProcess?.() unlistenProcess?.()
}) })
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis: _openAIAnalysis }) defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis })
</script> </script>
<template> <template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
frameTitle: {
id: 'app.starlight-skin.frame-title',
defaultMessage: 'StarLight Skin Site',
},
})
</script>
<template>
<iframe
:src="skinSiteFrameUrl"
allow="clipboard-write; clipboard-read"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
/>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { clearHostedSession } from '@/helpers/hosted-packs'
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
watch(
[skinSiteStatus, () => skinSiteUser.value?.uuid],
() => {
void clearHostedSession().catch(() => {})
},
{ flush: 'sync' },
)
function connect() {
resetSkinSiteSession()
lastMessage = 0
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
}
onMounted(() => {
window.addEventListener('message', receive)
connect()
expiryTimer = setInterval(() => {
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
}, 10_000)
})
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
void clearHostedSession().catch(() => {})
})
</script>
<template>
<iframe
ref="frame"
:src="`${SKIN_SITE_ORIGIN}/`"
title="StarLight Skin Site session"
aria-hidden="true"
tabindex="-1"
class="pointer-events-none fixed -left-[10000px] top-0 h-px w-px opacity-0"
@load="connect"
/>
</template>
@@ -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>
@@ -0,0 +1,274 @@
type Point = [number, number]
type Quad = [Point, Point, Point, Point]
type ShieldSurface = { plane: Quad; outline?: Point[]; shade?: number }
// Coordinates follow the sharp master with its localized shoulder cleanup,
// normalized to the scene's 2048 × 682⅔ plate.
// Each visible bone has its own face: the air between ribs must never receive armor.
export const witherShieldSurfaces: ShieldSurface[] = [
// Only the upper chest remains; the removed side beams have no shield surfaces.
{
plane: [
[1194, 479],
[1294, 489],
[1283, 516],
[1183, 505],
],
},
// Far central skull: face, jaw wall and right side share the same edges.
{
plane: [
[1162, 355],
[1319, 352],
[1267, 446],
[1108, 442],
],
},
{
plane: [
[1108, 442],
[1267, 446],
[1280, 489],
[1119, 470],
],
shade: 0.68,
},
{
plane: [
[1319, 352],
[1356, 443],
[1319, 492],
[1267, 446],
],
shade: 0.6,
},
// Near-left skull, underneath the boot.
{
plane: [
[794, 477],
[968, 454],
[1003, 549],
[833, 602],
],
},
{
plane: [
[794, 477],
[833, 602],
[821, 625],
[781, 539],
],
shade: 0.55,
},
{
plane: [
[833, 602],
[1003, 549],
[983, 587],
[821, 625],
],
shade: 0.65,
},
// Right skull.
{
plane: [
[1482, 399],
[1639, 469],
[1568, 582],
[1402, 507],
],
},
{
plane: [
[1639, 469],
[1650, 557],
[1597, 631],
[1568, 582],
],
shade: 0.57,
},
{
plane: [
[1402, 507],
[1568, 582],
[1597, 631],
[1414, 583],
],
shade: 0.66,
outline: [
[1402, 507],
[1568, 582],
[1597, 631],
[1561, 637],
[1444, 621],
[1414, 583],
],
},
// Rear rib, left and right of the chest opening.
{
plane: [
[1021, 505],
[1129, 484],
[1118, 501],
[1009, 520],
],
},
{
plane: [
[1009, 520],
[1118, 501],
[1112, 515],
[1006, 535],
],
shade: 0.65,
},
{
plane: [
[1191, 486],
[1300, 510],
[1289, 530],
[1181, 510],
],
},
{
plane: [
[1289, 530],
[1300, 510],
[1310, 611],
[1296, 621],
],
shade: 0.6,
},
// Middle rib and broken sternum: separate profiles preserve the dark gaps.
{
plane: [
[1052, 514],
[1159, 519],
[1129, 553],
[992, 548],
],
outline: [
[1015, 535],
[1052, 520],
[1094, 522],
[1110, 514],
[1159, 519],
[1129, 553],
[992, 548],
],
},
{
plane: [
[992, 548],
[1129, 553],
[1137, 575],
[987, 567],
],
shade: 0.6,
},
{
plane: [
[1159, 519],
[1170, 560],
[1137, 575],
[1129, 553],
],
shade: 0.55,
},
{
plane: [
[1180, 513],
[1289, 551],
[1274, 575],
[1159, 538],
],
},
{
plane: [
[1274, 575],
[1289, 551],
[1298, 617],
[1279, 632],
],
shade: 0.62,
},
// Closest right rib; its downturned end stops above the ground.
{
plane: [
[1157, 542],
[1261, 581],
[1243, 602],
[1141, 564],
],
},
{
plane: [
[1141, 564],
[1243, 602],
[1255, 641],
[1148, 605],
],
shade: 0.7,
},
{
plane: [
[1243, 602],
[1261, 581],
[1270, 627],
[1255, 641],
],
shade: 0.55,
},
// Foreground rib and spine.
{
plane: [
[1000, 566],
[1140, 581],
[1107, 604],
[960, 589],
],
},
{
plane: [
[960, 589],
[1107, 604],
[1112, 680],
[968, 622],
],
shade: 0.6,
outline: [
[960, 589],
[1107, 604],
[1112, 680],
[1070, 681],
[1066, 619],
[968, 611],
],
},
{
plane: [
[1107, 604],
[1140, 581],
[1160, 669],
[1112, 680],
],
shade: 0.5,
},
{
plane: [
[986, 611],
[1070, 617],
[967, 684],
[874, 671],
],
},
{
plane: [
[1070, 617],
[1074, 668],
[1060, 684],
[967, 684],
],
shade: 0.55,
},
]
@@ -0,0 +1,953 @@
import cloudTextureUrl from '@/assets/about-scene/clouds.png'
import moonTextureUrl from '@/assets/about-scene/moon-phases.png'
import swordTextureUrl from '@/assets/about-scene/netherite-sword.png'
import cleanUrl from '@/assets/about-scene/victory-clean.jpg'
import originalUrl from '@/assets/about-scene/victory-master.jpg'
import correctedPoseUrl from '@/assets/about-scene/victory-pose.jpg'
import removedBeamsUrl from '@/assets/about-scene/wither-beams-removed.png'
import rebuiltWitherUrl from '@/assets/about-scene/wither-shoulder-cleanup.png'
import { witherShieldSurfaces } from './wither-shield'
type Point = [number, number]
type Vertex = [number, number, number]
/** The approved artwork and item geometry are kept separate from the animation lifecycle. */
export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
const context = canvas.getContext('2d')
if (!context) throw new Error('Unable to initialize about-page canvas')
const ctx = context
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
const original = new Image()
const clean = new Image()
const correctedPose = new Image()
const rebuiltWither = new Image()
const removedBeams = new Image()
const swordTexture = new Image()
const cloudTexture = new Image()
const moonTexture = new Image()
const W = 2048,
H = W / 3,
artworkWidth = 2172,
artworkHeight = 724,
artworkScale = artworkWidth / W,
TAU = Math.PI * 2
let time = 0,
last = 0,
paused = false,
visible = false,
ready = false
let frame = 0,
destroyed = false
const makeLayer = (w = W, h = H) => {
const layer = document.createElement('canvas')
layer.width = Math.ceil(w)
layer.height = Math.ceil(h)
return layer
}
const base = makeLayer(artworkWidth, artworkHeight)
const shield = makeLayer(artworkWidth, artworkHeight)
const star = makeLayer(180, 180)
const starFace = makeLayer(180, 180)
const swordFace = makeLayer(128, 128)
const grippingFingers = makeLayer(64, 80)
let cloudField: ReturnType<typeof makeVanillaCloudField>
const buriedBlade: Point[] = [
[1088, 526],
[1099, 505],
[1105, 491],
[1116, 490],
[1124, 483],
[1133, 485],
[1142, 478],
[1158, 477],
[1195, 480],
[1212, 534],
[1169, 567],
[1101, 555],
]
const wound: Point[] = [
[1104, 507],
[1116, 490],
[1136, 490],
[1140, 480],
[1156, 477],
[1178, 479],
[1173, 493],
[1157, 505],
[1152, 524],
[1137, 534],
[1126, 523],
]
const starRows = [
'....p....',
'...pwp...',
'..pwYwp..',
'.pwYIYwp.',
'pwYISIYwp',
'.pwYIYwp.',
'..pwYwp..',
'...pwp...',
'....p....',
]
const starColors: Record<string, string> = {
p: '#b692d0',
w: '#f8e6cf',
Y: '#ffe3a0',
I: '#fff6da',
S: '#fffef1',
}
// Irregular failing-lamp events: brief reignitions, weak sputters, and dark gaps.
const shieldEvents = [
[0, 0.68, 0.48],
[0.87, 0.09, 0.82],
[1.05, 0.16, 0.27],
[1.39, 0.08, 0.61],
[2.7, 0.42, 0.2],
[3.27, 0.13, 0.65],
[4.8, 0.82, 0.42],
[5.83, 0.06, 0.82],
[6.08, 0.13, 0.29],
[6.39, 0.09, 0.7],
[8.15, 0.53, 0.26],
[9.64, 0.12, 0.69],
[9.88, 0.19, 0.34],
[10.2, 0.08, 0.73],
[11.75, 0.7, 0.45],
[12.8, 0.07, 0.87],
[13.14, 0.1, 0.24],
[15.02, 0.28, 0.5],
[15.58, 0.09, 0.86],
[15.82, 0.13, 0.31],
[17.5, 0.84, 0.22],
[18.63, 0.08, 0.68],
[18.85, 0.18, 0.39],
[20.25, 0.56, 0.53],
[21.13, 0.1, 0.86],
[21.36, 0.12, 0.35],
[22.17, 0.29, 0.18],
]
const rand = (n: number) => {
const v = Math.sin(n * 127.1 + 311.7) * 43758.5453
return v - Math.floor(v)
}
const sparks = Array.from({ length: 34 }, (_, i) => ({
seed: i,
phase: rand(i),
life: 6,
size: 2 + rand(i + 9) * 5,
}))
const dust = Array.from({ length: 56 }, (_, i) => ({
seed: i + 80,
phase: rand(i + 31),
life: 6 + rand(i + 42) * 5,
size: 4 + rand(i + 64) * 7,
}))
const armor: Point[][] = [
[
[655, 22],
[809, 72],
[885, 81],
[858, 132],
[773, 119],
[690, 132],
[651, 112],
],
[
[693, 214],
[768, 223],
[805, 385],
[727, 398],
[712, 301],
],
[
[627, 157],
[670, 171],
[649, 237],
[596, 221],
],
[
[877, 183],
[918, 210],
[899, 244],
[865, 224],
],
]
function path(points: Point[]) {
ctx.beginPath()
points.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
}
function glow(x: number, y: number, r: number, color: string, alpha: number) {
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r)
gradient.addColorStop(0, `rgba(${color},${alpha})`)
gradient.addColorStop(0.35, `rgba(${color},${alpha * 0.36})`)
gradient.addColorStop(1, `rgba(${color},0)`)
ctx.fillStyle = gradient
ctx.fillRect(x - r, y - r, r * 2, r * 2)
}
function prepare() {
// Restore the sharp master for all unaffected materials, anatomy and terrain.
const b = base.getContext('2d')!
b.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
b.imageSmoothingEnabled = false
b.drawImage(original, 0, 0, W, H)
const sky = makeLayer(artworkWidth, artworkHeight),
skyCtx = sky.getContext('2d')!
skyCtx.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
skyCtx.drawImage(clean, 0, 0, W, H)
skyCtx.globalCompositeOperation = 'destination-in'
const skyFade = skyCtx.createLinearGradient(0, 409, 0, 448)
skyFade.addColorStop(0, '#fff')
skyFade.addColorStop(1, 'transparent')
skyCtx.fillStyle = skyFade
skyCtx.fillRect(0, 0, W, 442)
b.drawImage(sky, 0, 0, W, H)
const restore = (source: HTMLImageElement, polygon: Point[]) => {
b.save()
b.beginPath()
polygon.forEach((p, i) => (i ? b.lineTo(...p) : b.moveTo(...p)))
b.closePath()
b.clip()
if (source === correctedPose)
b.drawImage(source, (583 * W) / 2172, (212 * W) / 2172, (510 * W) / 2172, (477 * W) / 2172)
else b.drawImage(source, 0, 0, W, H)
b.restore()
}
// Crisp original character; only previously corrected hands/boots use their local plate.
restore(original, [
[687, 8],
[906, 81],
[884, 173],
[919, 183],
[923, 209],
[876, 242],
[854, 273],
[862, 314],
[918, 308],
[938, 385],
[950, 424],
[930, 480],
[817, 480],
[805, 399],
[747, 399],
[720, 491],
[627, 502],
[651, 423],
[625, 452],
[590, 437],
[574, 410],
[594, 386],
[572, 374],
[577, 341],
[574, 331],
[593, 270],
[605, 237],
[588, 237],
[607, 159],
[650, 139],
])
// Remove the baked angled moon locally with matching moon-free sky.
// The new vanilla moon is a separate, camera-facing layer.
const moonSky = makeLayer(280, 270),
ms = moonSky.getContext('2d')!
ms.drawImage(
clean,
0,
0,
(280 * clean.naturalWidth) / W,
(270 * clean.naturalHeight) / H,
0,
0,
280,
270,
)
ms.globalCompositeOperation = 'destination-in'
ms.save()
ms.translate(140, 130)
ms.scale(1, 1.13)
const moonMask = ms.createRadialGradient(0, 0, 80, 0, 0, 123)
moonMask.addColorStop(0, '#fff')
moonMask.addColorStop(1, 'transparent')
ms.fillStyle = moonMask
ms.fillRect(-140, -135, 280, 270)
ms.restore()
b.drawImage(moonSky, 193, -20)
// Restore the sharp skull planes covered by the sky plate.
restore(original, [
[1159, 355],
[1318, 352],
[1358, 421],
[1315, 498],
[1246, 474],
[1107, 443],
])
// Remove the old painted blade without resampling the rest of the creature.
restore(clean, [
[975, 339],
[1002, 334],
[1078, 369],
[1148, 456],
[1185, 469],
[1200, 481],
[1178, 520],
[1144, 562],
[1080, 570],
[1025, 548],
[1053, 502],
[1006, 476],
])
// Retain the previously approved removal of baked lightning below the skulls.
restore(clean, [
[900, 595],
[948, 572],
[1000, 588],
[982, 615],
[914, 630],
[877, 625],
])
restore(clean, [
[1380, 420],
[1428, 430],
[1400, 492],
[1375, 535],
[1356, 606],
[1377, 626],
[1294, 639],
[1284, 579],
[1318, 525],
[1320, 470],
])
// Use regeneration only to erase the two unwanted shoulder slabs. The skulls,
// ribs, character and background retain their original sharp source pixels.
restore(rebuiltWither, [
[963, 423],
[1014, 417],
[1049, 431],
[1074, 479],
[1066, 495],
[1017, 511],
[985, 524],
[963, 466],
])
restore(rebuiltWither, [
[1360, 415],
[1392, 414],
[1429, 427],
[1448, 445],
[1405, 506],
[1398, 532],
[1355, 528],
[1319, 514],
[1324, 494],
])
// Replace only the removed beam silhouettes with the newly exposed background.
// No full-frame regeneration or feathering: unaffected source pixels stay intact.
restore(removedBeams, [
[976, 480],
[1146, 462],
[1152, 488],
[1124, 508],
[999, 530],
[990, 542],
])
restore(removedBeams, [
[1294, 489],
[1404, 498],
[1445, 548],
[1452, 629],
[1402, 621],
[1305, 615],
[1299, 549],
[1283, 521],
])
restore(correctedPose, [
[811, 377],
[951, 377],
[975, 415],
[987, 481],
[952, 496],
[813, 486],
])
restore(correctedPose, [
[591, 486],
[744, 495],
[757, 650],
[550, 650],
[565, 553],
])
restore(correctedPose, [
[880, 210],
[925, 218],
[951, 235],
[977, 245],
[979, 280],
[957, 302],
[932, 299],
[910, 280],
[877, 267],
])
try {
cloudField = makeVanillaCloudField()
} catch (error) {
console.warn('Unable to initialize about-page clouds', error)
}
// Occlude with skin pixels only: background in the palm must not erase the grip.
const fingers = grippingFingers.getContext('2d')!
fingers.drawImage(
clean,
(922 * clean.naturalWidth) / W,
(228 * clean.naturalHeight) / H,
(64 * clean.naturalWidth) / W,
(80 * clean.naturalHeight) / H,
0,
0,
64,
80,
)
const skin = fingers.getImageData(0, 0, 64, 80)
for (let pixel = 0; pixel < skin.data.length; pixel += 4) {
const [red, green, blue] = skin.data.subarray(pixel, pixel + 3)
if (red < 90 || green < 48 || red < green * 1.04 || green < blue * 1.08)
skin.data[pixel + 3] = 0
}
fingers.putImageData(skin, 0, 0)
// A small extruded item sprite retains sharp pixels during its vertical-axis turn.
const s = star.getContext('2d')!
starRows.forEach((row, y) =>
[...row].forEach((v, x) => {
if (v !== '.') {
s.fillStyle = starColors[v]
s.fillRect(x * 20, y * 20, 20, 20)
}
}),
)
}
function enchantedFace(
target: HTMLCanvasElement,
texture: CanvasImageSource,
t: number,
strength: number,
) {
const c = target.getContext('2d')!,
size = target.width
c.clearRect(0, 0, size, size)
c.imageSmoothingEnabled = false
c.globalCompositeOperation = 'source-over'
c.drawImage(texture, 0, 0, size, size)
c.globalCompositeOperation = 'source-atop'
c.fillStyle = `rgba(123,63,207,${strength * 0.14})`
c.fillRect(0, 0, size, size)
// Translation of glint only: the texture coordinates and geometry never change.
const shift = ((t / 6) % 1) * size * 1.25
for (let band = -2; band < 3; band++) {
const x = shift + band * size * 1.25
const g = c.createLinearGradient(x - size * 0.7, 0, x + size * 0.3, size)
g.addColorStop(0, 'rgba(135,72,236,0)')
g.addColorStop(0.34, 'rgba(135,72,236,0)')
g.addColorStop(0.46, `rgba(163,99,247,${strength * 0.6})`)
g.addColorStop(0.5, `rgba(224,178,255,${strength})`)
g.addColorStop(0.55, `rgba(151,81,244,${strength * 0.5})`)
g.addColorStop(0.66, 'rgba(135,72,236,0)')
g.addColorStop(1, 'rgba(135,72,236,0)')
c.fillStyle = g
c.fillRect(0, 0, size, size)
}
c.globalCompositeOperation = 'source-over'
return target
}
function drawSword(t: number) {
const face = enchantedFace(swordFace, swordTexture, t, 0.34)
ctx.save()
// Hide only the buried sword pixels; never paint a base-image patch over the shield.
ctx.beginPath()
ctx.rect(0, 0, W, H)
buriedBlade.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
ctx.clip('evenodd')
// Original 128x128 asset is rigidly rotated/scaled, with no mesh warp or repainting.
// Place the grip's actual texture center inside the corrected curled palm.
ctx.translate(938, 268)
ctx.rotate((95.5 * Math.PI) / 180)
ctx.scale(2.12, 2.12)
ctx.imageSmoothingEnabled = false
ctx.save()
ctx.translate(1.25, 1.25)
ctx.filter = 'brightness(.55)'
ctx.drawImage(swordTexture, -24, -104, 128, 128)
ctx.restore()
ctx.drawImage(face, -24, -104, 128, 128)
ctx.restore()
// The fingers occlude the handle so it remains held, rather than pasted over the hand.
ctx.drawImage(grippingFingers, 922, 228)
}
function drawStar(t: number, angle: number, x: number, y: number) {
const front = enchantedFace(starFace, star, t, 0.23)
const cosine = Math.cos(angle),
sine = Math.sin(angle),
halfDepth = 5
ctx.save()
ctx.translate(x, y)
ctx.imageSmoothingEnabled = false
// One native item-texel layer of thickness; keep the front's pixel silhouette intact.
const edge = sine > 0 ? -1 : 1
for (let row = 0; row < 9; row++)
for (let col = 0; col < 9; col++) {
const color = starRows[row][col]
if (color === '.') continue
const neighbor = starRows[row][col + edge]
if (neighbor && neighbor !== '.') continue
const px = ((col + (edge > 0 ? 1 : 0)) / 9 - 0.5) * 160,
py = (row / 9 - 0.5) * 160
const a = px * cosine - halfDepth * sine,
b = px * cosine + halfDepth * sine
ctx.fillStyle = color === 'p' ? '#9070b0' : '#d9af67'
ctx.beginPath()
ctx.moveTo(a, py)
ctx.lineTo(b, py)
ctx.lineTo(b, py + 160 / 9)
ctx.lineTo(a, py + 160 / 9)
ctx.closePath()
ctx.fill()
}
ctx.translate(Math.sign(cosine) * halfDepth * sine, 0)
ctx.scale(cosine, 1)
ctx.filter = 'brightness(1.04)'
ctx.shadowColor = '#ffdc98'
ctx.shadowBlur = 9
ctx.drawImage(front, -80, -80, 160, 160)
ctx.restore()
}
function shieldLevel(t: number) {
let level = 0
for (const [start, duration, power] of shieldEvents) {
const p = (t - start) / duration
if (p >= 0 && p < 1) {
const envelope = Math.min(1, p * 14, (1 - p) * 10)
const sputter = 0.64 + 0.36 * rand(Math.floor(t * 19) + start * 91)
level = Math.max(level, power * envelope * sputter)
}
}
return level
}
function makeVanillaCloudField() {
// Minecraft 1.20.6's unmodified clouds.png supplies every occupied cell.
// One texel is a 12 x 12 block footprint with the vanilla 4-block thickness.
const textureCanvas = makeLayer(256, 256),
tc = textureCanvas.getContext('2d')!
tc.drawImage(cloudTexture, 0, 0)
const pixels = tc.getImageData(0, 0, 256, 256).data
const occupied = (x: number, z: number) =>
pixels[(((z + 256) % 256) * 256 + ((x + 256) % 256)) * 4 + 3] > 127
const layer = makeLayer()
const context = layer.getContext('webgl', {
alpha: true,
antialias: true,
premultipliedAlpha: true,
})
if (!context) return
const gl = context
const vertices: number[] = []
const quad = (a: Vertex, b: Vertex, c: Vertex, d: Vertex, shade: number) => {
for (const point of [a, b, c, a, c, d]) vertices.push(...point, shade)
}
for (let z = 8; z < 96; z++)
for (let x = 0; x < 256; x++) {
if (!occupied(x, z + 36)) continue
const l = (x - 128) * 12,
r = l + 12,
n = z * 12,
f = n + 12,
y = 84,
h = y + 4
quad([l, y, n], [r, y, n], [r, y, f], [l, y, f], 0.7)
if (!occupied(x - 1, z + 36)) quad([l, y, n], [l, y, f], [l, h, f], [l, h, n], 0.9)
if (!occupied(x + 1, z + 36)) quad([r, y, f], [r, y, n], [r, h, n], [r, h, f], 0.9)
if (!occupied(x, z + 35)) quad([r, y, n], [l, y, n], [l, h, n], [r, h, n], 0.8)
if (!occupied(x, z + 37)) quad([l, y, f], [r, y, f], [r, h, f], [l, h, f], 0.8)
}
const shader = (kind: number, source: string) => {
const result = gl.createShader(kind)
if (!result) throw new Error('Unable to allocate cloud shader')
gl.shaderSource(result, source)
gl.compileShader(result)
if (!gl.getShaderParameter(result, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(result) ?? 'Cloud shader compilation failed')
return result
}
const vertex = shader(
gl.VERTEX_SHADER,
`
attribute vec4 vertex; uniform float offset;
varying float shade; varying float distance;
void main(){
vec3 p=vertex.xyz+vec3(offset,0.0,0.0);
float focal=650.0; float horizon=350.0;
gl_Position=vec4(2.0*focal*p.x/2048.0,
(1.0-2.0*horizon/682.6667)*p.z+2.0*focal*p.y/682.6667,
1.020202*p.z-28.282828,p.z);
shade=vertex.w; distance=p.z;
}
`,
)
const fragment = shader(
gl.FRAGMENT_SHADER,
`
precision mediump float; varying float shade; varying float distance;
void main(){
float alpha=.70*(1.0-smoothstep(480.0,1150.0,distance));
gl_FragColor=vec4(vec3(.34,.40,.50)*shade*alpha,alpha);
}
`,
)
const program = gl.createProgram()
if (!program) throw new Error('Unable to allocate cloud program')
gl.attachShader(program, vertex)
gl.attachShader(program, fragment)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
throw new Error(gl.getProgramInfoLog(program) ?? 'Cloud program linking failed')
gl.useProgram(program)
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)
const attribute = gl.getAttribLocation(program, 'vertex')
gl.enableVertexAttribArray(attribute)
gl.vertexAttribPointer(attribute, 4, gl.FLOAT, false, 16, 0)
const offset = gl.getUniformLocation(program, 'offset')
gl.enable(gl.DEPTH_TEST)
gl.disable(gl.BLEND)
gl.clearColor(0, 0, 0, 0)
gl.viewport(0, 0, layer.width, layer.height)
return {
draw(elapsed: number) {
if (gl.isContextLost()) return
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
const travel = (elapsed * 2.4) % 3072
for (const tile of [-3072, 0, 3072]) {
gl.uniform1f(offset, tile + travel)
gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 4)
}
return layer
},
resize(width: number, height: number) {
layer.width = width
layer.height = height
gl.viewport(0, 0, width, height)
},
dispose() {
gl.deleteBuffer(buffer)
gl.deleteProgram(program)
gl.deleteShader(vertex)
gl.deleteShader(fragment)
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
}
}
function drawShield(t: number) {
const level = shieldLevel(t)
canvas.dataset.shieldIntensity = level.toFixed(3)
if (level < 0.003) return
const surface = shield.getContext('2d')!
surface.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
surface.clearRect(0, 0, W, H + 1)
surface.save()
surface.globalCompositeOperation = 'source-over'
const trace = (points: Point[]) => {
surface.beginPath()
points.forEach((p, i) => (i ? surface.lineTo(...p) : surface.moveTo(...p)))
surface.closePath()
}
// Tight fractured-cavity contour: blue armor remains on the surrounding ribs.
surface.beginPath()
surface.rect(0, 0, W, H)
wound.forEach((p, i) => (i ? surface.lineTo(p[0], p[1]) : surface.moveTo(p[0], p[1])))
surface.closePath()
surface.clip('evenodd')
witherShieldSurfaces.forEach(({ plane: quad, outline = quad, shade = 1 }, index) => {
const map = (u: number, v: number): Point => [
(1 - v) * ((1 - u) * quad[0][0] + u * quad[1][0]) +
v * ((1 - u) * quad[3][0] + u * quad[2][0]),
(1 - v) * ((1 - u) * quad[0][1] + u * quad[1][1]) +
v * ((1 - u) * quad[3][1] + u * quad[2][1]),
]
const alpha = level * shade * (0.79 + 0.21 * rand(index + Math.floor(t * 7)))
trace(outline)
surface.fillStyle = `rgba(55,126,203,${alpha * 0.38})`
surface.fill()
surface.save()
trace(outline)
surface.clip()
for (let row = 0; row < 8; row++)
for (let col = 0; col < 8; col++) {
const grain = rand(row * 17 + col * 5 + index * 139)
if (grain < 0.56) continue
trace([
map(col / 8, row / 8),
map((col + 1) / 8, row / 8),
map((col + 1) / 8, (row + 1) / 8),
map(col / 8, (row + 1) / 8),
])
surface.fillStyle = `rgba(104,167,223,${alpha * (grain - 0.4) * 0.42})`
surface.fill()
}
// Stepped translucent bands wrap each actual surface, like the supplied armor image.
for (let band = -1; band < 3; band++)
for (let col = 0; col < 8; col++) {
const v = band * 0.48 + (t / 24) * 0.48 + Math.floor(rand(col + index * 11) * 3) / 32
const height = 0.034 + rand(col * 3 + band + index) * 0.022
trace([
map(col / 8, v),
map((col + 1) / 8, v),
map((col + 1) / 8, v + height),
map(col / 8, v + height),
])
surface.fillStyle = `rgba(210,229,172,${alpha * 0.75})`
surface.fill()
}
surface.restore()
})
surface.restore()
// Composite the fitted faces once, without bright overlaps at shared bone edges.
ctx.save()
ctx.globalCompositeOperation = 'screen'
ctx.drawImage(shield, 0, 0, W, H)
ctx.restore()
}
function drawClouds(elapsed: number) {
ctx.save()
// Clouds pass behind the character silhouette, never across the face or armor.
ctx.beginPath()
ctx.rect(0, 0, W, H)
;[
[687, 7],
[907, 80],
[884, 175],
[918, 182],
[921, 208],
[980, 246],
[980, 299],
[945, 306],
[899, 280],
[864, 260],
[861, 325],
[573, 325],
[594, 241],
[588, 238],
[607, 159],
[650, 139],
].forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
ctx.clip('evenodd')
const clouds = cloudField?.draw(elapsed)
if (clouds) ctx.drawImage(clouds, 0, 0, W, H)
ctx.restore()
canvas.dataset.cloudOffset = (elapsed * 2.4).toFixed(2)
}
function drawMoon() {
ctx.save()
ctx.globalCompositeOperation = 'screen'
ctx.imageSmoothingEnabled = false
ctx.filter = 'brightness(2.15)'
// Vanilla waning-crescent tile, rendered square-on with no skew or side extrusion.
// Retain its original pixel shading and native halo; black texels add no light.
ctx.drawImage(moonTexture, 96, 0, 32, 32, 45, -179, 576, 576)
ctx.restore()
}
function draw(elapsed: number) {
const t = elapsed % 24
const ratio = canvas.width / W
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
ctx.clearRect(0, 0, W, H)
const cycle = (t * TAU) / 24
ctx.save()
// Keep the static plate pixel-stable; animate only the independent effects.
// The full-resolution master is sampled once, without a floating camera zoom.
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(base, 0, 0, W, H)
drawMoon()
drawClouds(elapsed)
drawShield(t)
drawSword(t)
const bob = 5.2 * Math.sin(cycle * 3) + 1.3 * Math.sin(cycle * 6 + 0.4)
const sx = 1150,
sy = 247 + bob
const pulse = 0.8 + 0.12 * Math.sin(cycle * 3 + 0.5) + 0.06 * Math.sin(cycle * 7)
ctx.save()
ctx.globalCompositeOperation = 'screen'
glow(sx, sy, 195, '255,195,102', 0.42 * pulse)
glow(sx, sy, 88, '255,232,177', 0.15 * pulse)
glow(1134, 495, 165, '255,194,101', 0.13 * pulse)
glow(790, 191, 90, '255,201,129', 0.055 * pulse)
// Narrow rays breathe with the star, instead of rotating the whole scene.
ctx.translate(sx, sy)
for (let i = 0; i < 8; i++) {
ctx.save()
ctx.rotate((i * TAU) / 8 + 0.08 * Math.sin(cycle))
const g = ctx.createLinearGradient(0, 35, 0, 155)
g.addColorStop(0, `rgba(255,228,165,${0.13 * pulse})`)
g.addColorStop(1, 'transparent')
ctx.fillStyle = g
ctx.beginPath()
ctx.moveTo(-2, 30)
ctx.lineTo(-6, 155)
ctx.lineTo(6, 155)
ctx.lineTo(2, 30)
ctx.fill()
ctx.restore()
}
ctx.restore()
ctx.save()
ctx.globalCompositeOperation = 'screen'
for (const polygon of armor) {
ctx.save()
path(polygon)
ctx.clip()
const x = ((t / 8) % 1) * 1350 - 240
const glint = ctx.createLinearGradient(x - 100, 0, x + 150, 450)
glint.addColorStop(0, 'transparent')
glint.addColorStop(0.4, 'rgba(160,88,255,0)')
glint.addColorStop(0.5, 'rgba(206,149,255,.28)')
glint.addColorStop(0.58, 'rgba(136,81,250,.09)')
glint.addColorStop(1, 'transparent')
ctx.fillStyle = glint
ctx.fillRect(550, 0, 650, 520)
ctx.restore()
}
ctx.restore()
// Staggered particles have invisible births/deaths, so the loop has no pop.
dust.forEach((d) => {
const q = (elapsed / d.life + d.phase) % 1,
alpha = Math.pow(Math.sin(Math.PI * q), 0.65) * 0.9
const x =
1145 +
(rand(d.seed) - 0.5) * 170 +
Math.sin(q * 4 + d.seed) * 24 +
q * (45 + rand(d.seed + 2) * 85)
const y = 490 - q * (180 + rand(d.seed + 9) * 130)
const size = d.size * (1 - q * 0.4)
ctx.fillStyle = `rgba(7,10,16,${alpha})`
ctx.fillRect(x, y, size, size)
if (d.seed % 3 === 0) {
ctx.fillRect(x - size * 0.65, y + size * 0.4, size * 0.7, size * 0.65)
ctx.fillRect(x + size * 0.65, y - size * 0.5, size * 0.55, size * 0.6)
}
})
ctx.save()
ctx.globalCompositeOperation = 'screen'
sparks.forEach((d) => {
const q = (t / d.life + d.phase) % 1,
alpha = Math.pow(Math.sin(Math.PI * q), 1.7) * 0.55
const x = 1140 + (rand(d.seed + 7) - 0.5) * 150 + Math.sin(q * 4 + d.seed) * 14
const y = 440 - q * (130 + rand(d.seed + 19) * 120)
ctx.fillStyle = `rgba(255,207,122,${alpha})`
ctx.fillRect(x, y, d.size * 0.45, d.size * 0.45)
})
ctx.restore()
const angle = cycle * 2 + 0.12 * Math.sin(cycle * 2)
drawStar(t, angle, sx, sy)
ctx.restore()
canvas.dataset.animationTime = elapsed.toFixed(3)
}
function canAnimate() {
return ready && !destroyed && !paused && visible && !document.hidden && !reducedMotion.matches
}
function tick(now: number) {
frame = 0
if (!canAnimate()) return
if (last) time += Math.min((now - last) / 1000, 0.1)
last = now
draw(time)
frame = requestAnimationFrame(tick)
}
function updatePlayback() {
if (frame) cancelAnimationFrame(frame)
frame = 0
last = 0
if (canAnimate()) frame = requestAnimationFrame(tick)
}
function resize() {
if (destroyed) return
const width = canvas.getBoundingClientRect().width
if (width <= 0) return
canvas.width = Math.min(
artworkWidth,
Math.max(1, Math.round(width * Math.min(2, devicePixelRatio || 1))),
)
canvas.height = Math.round(canvas.width / 3)
cloudField?.resize(canvas.width, canvas.height)
if (ready) draw(time)
}
const observer = new ResizeObserver(resize)
observer.observe(canvas)
const intersection = new IntersectionObserver((entries) => {
visible = entries[0].isIntersecting
updatePlayback()
})
intersection.observe(canvas)
document.addEventListener('visibilitychange', updatePlayback)
reducedMotion.addEventListener('change', updatePlayback)
const loaded = (img: HTMLImageElement, url: string) =>
new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('Unable to load about-page artwork'))
img.src = url
})
const imageAssets = [
original,
clean,
swordTexture,
correctedPose,
cloudTexture,
moonTexture,
rebuiltWither,
removedBeams,
]
const loading = Promise.all([
loaded(original, originalUrl),
loaded(clean, cleanUrl),
loaded(swordTexture, swordTextureUrl),
loaded(correctedPose, correctedPoseUrl),
loaded(cloudTexture, cloudTextureUrl),
loaded(moonTexture, moonTextureUrl),
loaded(rebuiltWither, rebuiltWitherUrl),
loaded(removedBeams, removedBeamsUrl),
])
.then(() => {
if (destroyed) return
prepare()
ready = true
resize()
updatePlayback()
})
.finally(() => {
for (const img of imageAssets) {
img.onload = null
img.onerror = null
}
})
return {
ready: loading,
setPaused(value: boolean) {
paused = value
updatePlayback()
},
dispose() {
if (destroyed) return
destroyed = true
updatePlayback()
observer.disconnect()
intersection.disconnect()
document.removeEventListener('visibilitychange', updatePlayback)
reducedMotion.removeEventListener('change', updatePlayback)
cloudField?.dispose()
cloudField = undefined
for (const layer of [base, shield, star, starFace, swordFace, grippingFingers]) {
layer.width = 1
layer.height = 1
}
},
}
}
@@ -61,7 +61,7 @@ const historyAnnouncements = computed(() =>
<UpdateAnnouncementContent <UpdateAnnouncementContent
:announcement="currentAnnouncement" :announcement="currentAnnouncement"
:version="currentVersion" :version="currentVersion"
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.website" :external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.releasesUrl"
/> />
</div> </div>
@@ -102,7 +102,7 @@ const historyAnnouncements = computed(() =>
<UpdateAnnouncementContent <UpdateAnnouncementContent
:announcement="announcement" :announcement="announcement"
:show-header="false" :show-header="false"
:external-url="announcement.externalUrl" :external-url="announcement.externalUrl ?? `${AxolotlBrandConfig.releasesUrl}/tag/v${announcement.version}`"
/> />
</div> </div>
</Accordion> </Accordion>
@@ -0,0 +1,366 @@
<script setup lang="ts">
import { NewButton as Button, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onMounted, onScopeDispose, ref, watch } from 'vue'
import type { Puzzle } from './engine'
import { messages } from './messages'
const props = defineProps<{
puzzle: Puzzle
revealed: boolean[]
selected: number
playing: boolean
lost: boolean
mistake: number
zoom: number
}>()
const emit = defineEmits<{
paint: [index: number]
'update:zoom': [value: number]
}>()
const { formatMessage } = useVIntl()
const viewport = ref<HTMLElement>()
const grid = ref<HTMLElement>()
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
const brushCursor = ref<string>()
const brushActive = computed(() => props.playing && props.selected >= 0)
const large = computed(() => props.puzzle.difficulty !== 'easy')
const visibleCells = computed(() =>
props.puzzle.answer.flatMap((color, i) =>
props.revealed[i] || props.lost ? [{ color, i }] : [],
),
)
const letter = (color: number) => String.fromCharCode(65 + color)
function updateView() {
const el = viewport.value
if (!el) return
const size = props.puzzle.size
view.value = {
left: (el.scrollLeft / el.scrollWidth) * size,
top: (el.scrollTop / el.scrollHeight) * size,
width: Math.min(size, (el.clientWidth / el.scrollWidth) * size),
height: Math.min(size, (el.clientHeight / el.scrollHeight) * size),
}
}
async function setView(left: number, top: number) {
await nextTick()
viewport.value?.scrollTo(left, top)
updateView()
}
async function centerFirstClue() {
await nextTick()
const el = viewport.value
if (!el || !large.value) return setView(0, 0)
const i = props.revealed.findIndex(Boolean)
await setView(
((i % props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientWidth / 2,
(Math.floor(i / props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientHeight / 2,
)
}
function navigate(event: MouseEvent) {
const rect = (event.currentTarget as SVGElement).getBoundingClientRect()
const el = viewport.value
if (!el) return
void setView(
((event.clientX - rect.left) / rect.width) * el.scrollWidth - el.clientWidth / 2,
((event.clientY - rect.top) / rect.height) * el.scrollHeight - el.clientHeight / 2,
)
}
async function changeZoom(value: number) {
const el = viewport.value
if (!el) return
const zoom = Math.max(0.7, Math.min(1.6, Math.round(value * 10) / 10))
const ratio = (40 * zoom + 3) / (40 * props.zoom + 3)
const x = (el.scrollLeft + el.clientWidth / 2) * ratio - el.clientWidth / 2
const y = (el.scrollTop + el.clientHeight / 2) * ratio - el.clientHeight / 2
emit('update:zoom', zoom)
await setView(x, y)
}
function onWheel(event: WheelEvent) {
if (!large.value || !event.ctrlKey) return
event.preventDefault()
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
}
let cursorColor = ''
function updateBrushCursor() {
if (!brushActive.value || !grid.value) return
const color = getComputedStyle(grid.value).color
if (color === cursorColor) return
cursorColor = color
// Let the native cursor follow the pointer independently of Vue and board rendering.
// The hotspot is the same brush tip as the previous 32px floating SVG.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="${color}" d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"/></svg>`
brushCursor.value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 5 27, crosshair`
}
watch(() => [props.playing, props.selected], updateBrushCursor, {
flush: 'post',
})
function label(index: number) {
const values = {
row: Math.floor(index / props.puzzle.size) + 1,
column: (index % props.puzzle.size) + 1,
}
return props.revealed[index] || props.lost
? formatMessage(messages.revealed, {
...values,
color: letter(props.puzzle.answer[index]),
number: props.puzzle.numbers[index],
})
: formatMessage(messages.hidden, values)
}
let observer: ResizeObserver | undefined
onMounted(() => {
updateBrushCursor()
observer = new ResizeObserver(updateView)
if (viewport.value) observer.observe(viewport.value)
})
onScopeDispose(() => observer?.disconnect())
defineExpose({
centerFirstClue,
setView,
getView: () => ({
left: viewport.value?.scrollLeft ?? 0,
top: viewport.value?.scrollTop ?? 0,
}),
})
</script>
<template>
<div class="mine-navigation" :class="{ 'mine-large': large }">
<div ref="viewport" class="mine-viewport" @scroll.passive="updateView" @wheel="onWheel">
<div
ref="grid"
class="mine-grid"
:class="{
'mine-grid-small': !large,
'mine-grid-brush-active': brushActive,
}"
:style="{
'--mine-size': puzzle.size,
'--mine-cell-size': `${40 * zoom}px`,
'--mine-brush-cursor': brushCursor,
color: selected >= 0 ? `var(--mine-color-${selected})` : undefined,
}"
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
@pointerenter="updateBrushCursor"
>
<button
v-for="(_, i) in puzzle.answer"
:key="i"
type="button"
class="mine-cell"
:class="{
'mine-open': revealed[i] || lost,
'mine-answer': lost && !revealed[i],
'mine-mistake': mistake === i,
'mine-sampled': revealed[i] && selected === puzzle.answer[i],
}"
:style="
revealed[i] || lost
? { '--mine-cell-color': `var(--mine-color-${puzzle.answer[i]})` }
: undefined
"
:disabled="!playing"
:aria-label="label(i)"
@click="emit('paint', i)"
>
<template v-if="revealed[i] || lost">
<span>{{ puzzle.numbers[i] }}</span
><small>{{ letter(puzzle.answer[i]) }}</small>
</template>
</button>
</div>
</div>
<div v-if="large" class="mine-overview">
<svg
class="mine-map"
:viewBox="`0 0 ${puzzle.size} ${puzzle.size}`"
role="img"
:aria-label="formatMessage(messages.map)"
@click="navigate"
>
<rect width="100%" height="100%" fill="var(--surface-1)" />
<rect
v-for="cell in visibleCells"
:key="cell.i"
:x="cell.i % puzzle.size"
:y="Math.floor(cell.i / puzzle.size)"
width="1"
height="1"
:fill="`var(--mine-color-${cell.color})`"
/>
<rect
:x="view.left"
:y="view.top"
:width="view.width"
:height="view.height"
fill="none"
stroke="var(--color-contrast)"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
</svg>
<div class="mine-zoom">
<Button
size="sm"
:disabled="zoom <= 0.7"
:aria-label="formatMessage(messages.zoomOut)"
@click="changeZoom(zoom - 0.1)"
>−</Button
>
<span>{{ Math.round(zoom * 100) }}%</span>
<Button
size="sm"
:disabled="zoom >= 1.6"
:aria-label="formatMessage(messages.zoomIn)"
@click="changeZoom(zoom + 0.1)"
>+</Button
>
</div>
</div>
</div>
</template>
<style scoped>
.mine-navigation {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
display: grid;
gap: var(--gap-md);
}
.mine-large {
grid-template-columns: minmax(0, 1fr) 7rem;
}
.mine-viewport {
box-sizing: border-box;
width: 100%;
max-width: 100%;
overflow: auto;
max-height: min(52vh, 32rem);
min-width: 0;
background: var(--surface-1);
border-radius: var(--radius-sm);
overscroll-behavior: contain;
}
.mine-grid {
display: grid;
grid-template-columns: repeat(var(--mine-size), var(--mine-cell-size));
/* Scrolling and minimap coordinates use a fixed square pitch at every zoom level. */
grid-auto-rows: var(--mine-cell-size);
gap: 3px;
width: max-content;
padding: 3px;
}
.mine-grid-small {
grid-template-columns: repeat(var(--mine-size), minmax(0, 1fr));
grid-auto-rows: auto;
width: min(100%, 26rem, 48vh);
margin: auto;
box-sizing: border-box;
}
.mine-cell {
position: relative;
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1;
padding: 0;
border: 1px solid color-mix(in srgb, var(--mine-ink) 38%, var(--surface-5));
border-radius: var(--radius-sm);
background: var(--surface-4);
color: var(--color-contrast);
font: inherit;
font-weight: 700;
cursor: pointer;
user-select: none;
touch-action: manipulation;
}
.mine-grid-brush-active,
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
cursor: var(--mine-brush-cursor, crosshair);
}
.mine-cell:hover:not(:disabled) {
background: var(--surface-5);
border-color: var(--mine-ink);
}
.mine-cell:focus-visible {
outline: 2px solid var(--color-contrast);
outline-offset: -3px;
z-index: 1;
}
.mine-open {
background: color-mix(in srgb, var(--mine-cell-color) 34%, var(--surface-2));
border-color: var(--mine-cell-color);
cursor: pointer;
}
.mine-open:hover:not(:disabled) {
background: color-mix(in srgb, var(--mine-cell-color) 50%, var(--surface-2));
}
.mine-sampled {
box-shadow: inset 0 0 0 1px var(--mine-cell-color);
}
.mine-cell small {
position: absolute;
right: 3px;
bottom: 1px;
font-size: 0.5rem;
line-height: 1;
}
.mine-answer {
opacity: 0.55;
}
.mine-cell:disabled {
cursor: default;
}
.mine-mistake {
opacity: 1;
outline: 3px solid var(--color-red);
outline-offset: -3px;
}
.mine-overview {
display: flex;
flex-direction: column;
gap: var(--gap-md);
align-items: center;
}
.mine-map {
display: block;
width: 7rem;
height: auto;
aspect-ratio: 1;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
cursor: crosshair;
}
.mine-zoom {
display: flex;
align-items: center;
gap: var(--gap-xs);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
@media (max-width: 600px) {
.mine-large {
grid-template-columns: minmax(0, 1fr);
}
.mine-overview {
flex-direction: row;
justify-content: space-between;
}
.mine-map {
width: 4.5rem;
}
}
</style>
@@ -0,0 +1,489 @@
<script setup lang="ts">
import './palette.css'
import { NewButton as Button, NewModal, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import ColorMineBoard from './ColorMineBoard.vue'
import { type Difficulty, LEVELS, type Puzzle } from './engine'
import { messages } from './messages'
import {
parseSave,
readBest,
recordBest,
SAVE_KEY,
type SavedGame,
saveGame,
validateResume,
} from './storage'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const board = ref<InstanceType<typeof ColorMineBoard>>()
const prompt = ref<HTMLElement>()
const puzzle = shallowRef<Puzzle>()
const revealed = ref<boolean[]>([])
const selected = ref(-1)
const mistake = ref(-1)
const elapsed = ref(0)
const zoom = ref(1)
const best = ref<number | null>(null)
const difficulty = ref<Difficulty>('easy')
const levels = Object.keys(LEVELS) as Difficulty[]
const screen = ref<'loading' | 'board' | 'saved' | 'error'>('loading')
const result = ref<'playing' | 'lost' | 'won'>('playing')
const confirmation = ref<'leave' | 'replace' | null>(null)
const saved = shallowRef<SavedGame | null>(null)
const storageError = ref(false)
const recordError = ref(false)
const needsColor = ref(false)
let nextDifficulty: Difficulty = 'easy'
let worker: Worker | undefined
let active = false
let closing = false
let started = false
let heldView = { left: 0, top: 0 }
let lastTick = performance.now()
const count = computed(() => revealed.value.filter(Boolean).length)
const letter = (color: number) => String.fromCharCode(65 + color)
const timeText = (time: number) => {
const seconds = Math.floor(time / 1000)
return `${Math.floor(seconds / 60)
.toString()
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
}
const status = computed(() => {
if (result.value === 'won') return formatMessage(messages.won)
if (result.value === 'lost' && puzzle.value)
return formatMessage(messages.lost, {
row: Math.floor(mistake.value / puzzle.value.size) + 1,
column: (mistake.value % puzzle.value.size) + 1,
selected: letter(selected.value),
correct: letter(puzzle.value.answer[mistake.value]),
})
return selected.value < 0 || needsColor.value
? formatMessage(messages.choose)
: formatMessage(messages.selected, { color: letter(selected.value) })
})
function tick() {
const now = performance.now()
if (
active &&
started &&
screen.value === 'board' &&
result.value === 'playing' &&
!confirmation.value &&
!document.hidden
) {
elapsed.value += now - lastTick
}
lastTick = now
}
let timer: number | undefined
function visibilityChanged() {
lastTick = performance.now()
}
document.addEventListener('visibilitychange', visibilityChanged)
async function show() {
if (active || closing) return
active = true
lastTick = performance.now()
timer = window.setInterval(tick, 250)
modal.value?.show()
loadSaved()
}
function loadSaved() {
storageError.value = false
saved.value = null
try {
saved.value = parseSave(localStorage.getItem(SAVE_KEY))
if (saved.value) {
difficulty.value = saved.value.difficulty
screen.value = 'saved'
} else start(difficulty.value)
} catch {
screen.value = 'saved'
storageError.value = true
}
}
function start(level: Difficulty, resume?: SavedGame) {
worker?.terminate()
confirmation.value = null
storageError.value = false
recordError.value = false
screen.value = 'loading'
difficulty.value = level
started = false
try {
const pending = new Worker(new URL('./generator.worker.ts', import.meta.url), {
type: 'module',
})
worker = pending
pending.onerror = () => {
if (worker !== pending) return
pending.terminate()
worker = undefined
screen.value = 'error'
}
pending.onmessage = async (event: MessageEvent<{ puzzle?: Puzzle; error?: boolean }>) => {
if (worker !== pending || !active) return
pending.terminate()
worker = undefined
if (!event.data.puzzle) {
screen.value = 'error'
return
}
const generated = event.data.puzzle
if (resume) {
try {
validateResume(resume, generated)
// Consume the save before playing so losing cannot reload an older, safe state.
localStorage.removeItem(SAVE_KEY)
} catch {
screen.value = 'saved'
storageError.value = true
return
}
}
saved.value = null
puzzle.value = generated
revealed.value = resume?.revealed.slice() ?? generated.clues.slice()
selected.value = resume?.selected ?? -1
elapsed.value = resume?.elapsed ?? 0
started = elapsed.value > 0
lastTick = performance.now()
zoom.value = resume?.zoom ?? 1
mistake.value = -1
needsColor.value = false
result.value = 'playing'
screen.value = 'board'
try {
best.value = readBest(localStorage, level)
} catch {
best.value = null
}
await nextTick()
if (resume) await board.value?.setView(resume.left, resume.top)
else await board.value?.centerFirstClue()
}
pending.postMessage({
seed: resume?.seed ?? crypto.getRandomValues(new Uint32Array(1))[0],
difficulty: level,
})
} catch {
worker?.terminate()
worker = undefined
screen.value = 'error'
}
}
function paint(index: number) {
const current = puzzle.value
if (!current || confirmation.value || result.value !== 'playing') return
if (revealed.value[index]) {
selected.value = current.answer[index]
needsColor.value = false
return
}
if (selected.value < 0) {
needsColor.value = true
return
}
tick()
started = true
if (selected.value !== current.answer[index]) {
mistake.value = index
result.value = 'lost'
return
}
revealed.value[index] = true
if (revealed.value.every(Boolean)) {
result.value = 'won'
try {
best.value = recordBest(localStorage, current.difficulty, elapsed.value)
} catch {
recordError.value = true
}
}
}
async function ask(kind: 'leave' | 'replace') {
tick()
heldView = board.value?.getView() ?? { left: 0, top: 0 }
confirmation.value = kind
await nextTick()
prompt.value?.focus()
}
function requestNew(level: Difficulty = difficulty.value) {
nextDifficulty = level
if (screen.value === 'board' && result.value === 'playing') void ask('replace')
else start(level)
}
function requestClose() {
if (confirmation.value) {
confirmation.value = null
lastTick = performance.now()
void nextTick(() => board.value?.setView(heldView.left, heldView.top))
return
}
if (screen.value === 'board' && result.value === 'playing') void ask('leave')
else void close()
}
async function close() {
if (closing) return
closing = true
active = false
window.clearInterval(timer)
timer = undefined
worker?.terminate()
worker = undefined
await modal.value?.hide()
confirmation.value = null
puzzle.value = undefined
closing = false
}
async function saveAndLeave() {
if (!puzzle.value) return
try {
saveGame(localStorage, {
version: 1,
seed: puzzle.value.seed,
difficulty: puzzle.value.difficulty,
revealed: revealed.value.slice(),
selected: selected.value,
elapsed: elapsed.value,
zoom: zoom.value,
left: heldView.left,
top: heldView.top,
})
await close()
} catch {
storageError.value = true
}
}
function discardSaved() {
try {
localStorage.removeItem(SAVE_KEY)
saved.value = null
start(difficulty.value)
} catch {
storageError.value = true
}
}
function keydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
requestClose()
}
}
onScopeDispose(() => {
worker?.terminate()
window.clearInterval(timer)
document.removeEventListener('visibilitychange', visibilityChanged)
})
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="56rem"
max-width="56rem"
scrollable
:closable="false"
:close-on-esc="false"
actions-divider
@keydown.stop="keydown"
>
<div
class="color-mine"
:style="{
'--mine-ink': selected >= 0 ? `var(--mine-color-${selected})` : 'var(--color-brand)',
}"
>
<div v-if="confirmation" ref="prompt" class="mine-prompt" tabindex="-1" role="alert">
<h3>
{{
formatMessage(confirmation === 'leave' ? messages.leaveTitle : messages.replaceTitle)
}}
</h3>
<p>
{{ formatMessage(confirmation === 'leave' ? messages.leaveText : messages.replaceText) }}
</p>
</div>
<div v-if="screen === 'board' && puzzle" v-show="!confirmation" class="mine-play">
<div class="mine-toolbar">
<div class="mine-levels">
<Button
v-for="level in levels"
:key="level"
size="sm"
:type="difficulty === level ? 'outlined' : 'base'"
:aria-pressed="difficulty === level"
@click="level !== difficulty && requestNew(level)"
>{{ formatMessage(messages[level]) }}</Button
>
</div>
<span class="mine-time">{{ timeText(elapsed) }}</span>
</div>
<div class="mine-frame">
<p class="mine-status" role="status">{{ status }}</p>
<ColorMineBoard
ref="board"
v-model:zoom="zoom"
:puzzle="puzzle"
:revealed="revealed"
:selected="selected"
:playing="result === 'playing'"
:lost="result === 'lost'"
:mistake="mistake"
@paint="paint"
/>
</div>
<div class="mine-stats">
<span>{{ formatMessage(messages.progress, { count, total: revealed.length }) }}</span>
<span v-if="best !== null">{{
formatMessage(messages.best, { time: timeText(best) })
}}</span>
</div>
<p v-if="result === 'lost'" class="mine-meta">{{ formatMessage(messages.review) }}</p>
<p v-if="recordError" role="alert">{{ formatMessage(messages.recordError) }}</p>
<details class="mine-rules">
<summary>{{ formatMessage(messages.rulesTitle) }}</summary>
<p>{{ formatMessage(messages.rules) }}</p>
</details>
</div>
<p v-else-if="screen === 'loading'" role="status">{{ formatMessage(messages.loading) }}</p>
<div v-else-if="screen === 'saved'" class="mine-prompt">
<h3>{{ formatMessage(messages.savedTitle) }}</h3>
<p v-if="saved">
{{ formatMessage(messages[saved.difficulty]) }} · {{ timeText(saved.elapsed) }}
</p>
</div>
<p v-else role="alert">{{ formatMessage(messages.generateError) }}</p>
<p v-if="storageError" role="alert">{{ formatMessage(messages.storageError) }}</p>
</div>
<template #actions>
<div class="mine-actions">
<template v-if="confirmation">
<Button @click="requestClose">{{ formatMessage(messages.continue) }}</Button>
<Button v-if="confirmation === 'leave'" @click="close">{{
formatMessage(messages.discard)
}}</Button>
<Button
type="colored"
color="brand"
@click="confirmation === 'leave' ? saveAndLeave() : start(nextDifficulty)"
>
{{
formatMessage(confirmation === 'leave' ? messages.saveLeave : messages.newGame)
}}</Button
>
</template>
<template v-else>
<Button @click="requestClose">{{ formatMessage(messages.close) }}</Button>
<template v-if="screen === 'saved'">
<Button @click="discardSaved">{{ formatMessage(messages.discard) }}</Button>
<Button
type="colored"
color="brand"
@click="saved ? start(saved.difficulty, saved) : loadSaved()"
>
{{ formatMessage(saved ? messages.resume : messages.retry) }}</Button
>
</template>
<Button
v-else-if="screen !== 'loading'"
:type="result === 'playing' && screen === 'board' ? 'base' : 'colored'"
color="brand"
@click="screen === 'error' && saved ? start(saved.difficulty, saved) : requestNew()"
>
{{ formatMessage(screen === 'error' ? messages.retry : messages.newGame) }}</Button
>
</template>
</div>
</template>
</NewModal>
</template>
<style scoped>
.color-mine {
display: flex;
flex-direction: column;
gap: var(--gap-md);
min-width: 0;
}
.mine-play {
display: flex;
flex-direction: column;
gap: var(--gap-md);
min-width: 0;
}
.mine-toolbar,
.mine-levels,
.mine-stats,
.mine-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gap-sm);
}
.mine-time {
margin-left: auto;
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--color-contrast);
}
.mine-meta,
.mine-stats,
.mine-rules {
color: var(--color-secondary);
font-size: 0.875rem;
}
.mine-stats {
justify-content: space-between;
}
.mine-frame {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
border: 2px solid var(--mine-ink);
border-radius: var(--radius-md);
padding: var(--gap-md);
background: var(--surface-2);
}
.mine-status {
min-width: 0;
max-width: 100%;
margin: 0 0 var(--gap-md);
color: var(--color-contrast);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.mine-actions {
justify-content: flex-end;
}
.mine-prompt h3,
.mine-prompt p {
margin: 0 0 var(--gap-md);
}
.mine-rules summary {
cursor: pointer;
}
.mine-rules p {
line-height: 1.6;
margin-bottom: 0;
}
</style>
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { type Board, createEngine, type Difficulty, generate, LEVELS } from './engine.ts'
function connected(board: Board) {
const { cross } = createEngine(board.size, board.colors)
for (let color = 0; color < board.colors; color++) {
const source = board.answer.indexOf(color)
if (source < 0) return false
const visited = new Set([source])
const pending = [source]
while (pending.length) {
for (const neighbor of cross[pending.pop()!]) {
if (board.answer[neighbor] === color && !visited.has(neighbor)) {
visited.add(neighbor)
pending.push(neighbor)
}
}
}
if (visited.size !== board.answer.filter((c) => c === color).length) return false
}
return true
}
for (const difficulty of Object.keys(LEVELS) as Difficulty[]) {
test(`${difficulty}: 20 seeds have connected colors, correct numbers and a complete no-guess path`, () => {
for (let seed = 1; seed <= 20; seed++) {
const puzzle = generate(seed, difficulty)
const engine = createEngine(puzzle.size, puzzle.colors)
assert.ok(connected(puzzle))
assert.ok(puzzle.clues.some((v) => !v))
assert.ok(
puzzle.clues.filter(Boolean).length >=
Math.ceil(puzzle.size ** 2 * LEVELS[difficulty].density),
)
for (let color = 0; color < puzzle.colors; color++) {
assert.ok(puzzle.clues.some((v, i) => v && puzzle.answer[i] === color))
}
assert.deepEqual(
puzzle.numbers,
puzzle.answer.map(
(color, i) => engine.around[i].filter((j) => puzzle.answer[j] === color).length,
),
)
assert.ok(engine.solve(puzzle, puzzle.clues).every(Boolean))
if (difficulty === 'hard') {
for (let i = 0; i < puzzle.clues.length; i++) {
if (
!puzzle.clues[i] ||
puzzle.clues.filter((v, j) => v && puzzle.answer[j] === puzzle.answer[i]).length <= 1
)
continue
const fewer = puzzle.clues.slice()
fewer[i] = false
assert.ok(
engine.solve(puzzle, fewer).some((v) => !v),
`Redundant clue at ${i}, seed ${seed}`,
)
}
}
}
})
}
test('seeded generation is deterministic for saved games', () => {
assert.deepEqual(generate(4294967295, 'normal'), generate(4294967295, 'normal'))
})
test('deductions agree with exhaustive valid 3 × 3 boards; unrevealed numbers never leak', () => {
const engine = createEngine(3, 2)
const boards: Board[] = []
for (let bits = 1; bits < 511; bits++) {
const answer = Array.from({ length: 9 }, (_, i) => (bits >> i) & 1)
const board = {
size: 3,
colors: 2,
answer,
numbers: answer.map((c, i) => engine.around[i].filter((j) => answer[j] === c).length),
}
if (connected(board)) boards.push(board)
}
for (const board of boards) {
const revealed = board.answer.map((color, i) => i === board.answer.indexOf(color))
const masks = engine.infer(board, revealed)
const candidates = boards.filter((candidate) =>
revealed.every(
(open, i) =>
!open ||
(candidate.answer[i] === board.answer[i] && candidate.numbers[i] === board.numbers[i]),
),
)
for (const candidate of candidates) {
candidate.answer.forEach((color, i) => assert.ok(masks[i] & (1 << color)))
}
const hiddenScrambled = {
...board,
answer: board.answer.map((c, i) => (revealed[i] ? c : 1 - c)),
numbers: board.numbers.map((n, i) => (revealed[i] ? n : 99)),
}
assert.deepEqual(engine.infer(hiddenScrambled, revealed), masks)
}
})
test('diagonal contact does not connect a color', () => {
assert.equal(
connected({ size: 2, colors: 2, answer: [0, 1, 1, 0], numbers: [1, 1, 1, 1] }),
false,
)
})
@@ -0,0 +1,275 @@
export const LEVELS = {
easy: { size: 10, colors: 4, density: 0.35 },
normal: { size: 25, colors: 6, density: 0.15 },
hard: { size: 60, colors: 10, density: 0 },
} as const
export type Difficulty = keyof typeof LEVELS
export interface Board {
size: number
colors: number
answer: number[]
numbers: number[]
}
export interface Puzzle extends Board {
clues: boolean[]
seed: number
difficulty: Difficulty
}
const singleton = (mask: number) => mask > 0 && (mask & (mask - 1)) === 0
export const colorOf = (mask: number) => 31 - Math.clz32(mask)
function randomSource(seed: number) {
return () => {
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function shuffle<T>(items: T[], random: () => number) {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
;[items[i], items[j]] = [items[j], items[i]]
}
return items
}
export function createEngine(size: number, colors: number) {
const count = size * size
const all = (1 << colors) - 1
const indices = Array.from({ length: count }, (_, i) => i)
const around = indices.map((i) => {
const neighbors: number[] = []
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const x = (i % size) + dx
const y = Math.floor(i / size) + dy
if ((dx || dy) && x >= 0 && x < size && y >= 0 && y < size) {
neighbors.push(y * size + x)
}
}
}
return neighbors
})
const distance = (a: number, b: number) =>
Math.abs((a % size) - (b % size)) + Math.abs(Math.floor(a / size) - Math.floor(b / size))
const cross = around.map((neighbors, i) => neighbors.filter((j) => distance(i, j) === 1))
function grow(random: () => number): Board {
const answer: number[] = Array(count).fill(-1)
const areas: number[] = Array(colors).fill(0)
const seeds: number[] = []
for (let color = 0; color < colors; color++) {
const candidates = shuffle(
indices.filter((i) => answer[i] < 0),
random,
).slice(0, 80)
if (color) {
candidates.sort(
(a, b) =>
Math.min(...seeds.map((s) => distance(b, s))) -
Math.min(...seeds.map((s) => distance(a, s))),
)
}
const seed = candidates[0]
answer[seed] = color
areas[color]++
seeds.push(seed)
}
const frontier: number[][] = Array.from({ length: colors }, () => [])
const queued = Array.from({ length: colors }, () => new Uint8Array(count))
function extend(i: number, color: number) {
for (const j of cross[i]) {
if (answer[j] < 0 && !queued[color][j]) {
frontier[color].push(j)
queued[color][j] = 1
}
}
}
seeds.forEach(extend)
let left = count - colors
while (left) {
const available = areas.map((_, c) => c).filter((c) => frontier[c].length)
available.sort((a, b) => areas[a] - areas[b])
if (!available.length) throw new Error('Incomplete region growth')
const color = available[Math.floor(random() * Math.min(2, available.length))]
const list = frontier[color]
const position = Math.floor(random() * list.length)
const i = list[position]
list[position] = list[list.length - 1]
list.pop()
if (answer[i] >= 0) continue
answer[i] = color
areas[color]++
left--
extend(i, color)
}
return {
size,
colors,
answer,
numbers: answer.map((color, i) => around[i].filter((j) => answer[j] === color).length),
}
}
// Only revealed cells supply numbers. Deduced cells become new clues after a safe paint.
function propagate(board: Board, revealed: boolean[], masks: number[]) {
let changed = true
while (changed) {
changed = false
for (const i of indices) {
if (!revealed[i]) continue
const bit = 1 << board.answer[i]
const target = board.numbers[i]
let sure = 0
const optional: number[] = []
for (const j of around[i]) {
if (masks[j] === bit) sure++
else if (masks[j] & bit) optional.push(j)
}
if (sure > target || sure + optional.length < target) throw new Error('Contradictory clue')
if (sure === target || sure + optional.length === target) {
for (const j of optional) {
masks[j] = sure === target ? masks[j] & ~bit : bit
changed = true
}
}
}
}
}
function connectivity(masks: number[]) {
let changed = false
for (let color = 0; color < colors; color++) {
const bit = 1 << color
const source = masks.indexOf(bit)
if (source < 0) continue
// Iterative Tarjan traversal avoids a recursive stack overflow on the 60 × 60 board.
const order = new Int32Array(count)
const low = new Int32Array(count)
const parent = new Int32Array(count).fill(-1)
const terminals = new Int32Array(count)
const edge = new Uint8Array(count)
const stack = [source]
let time = 1
order[source] = low[source] = terminals[source] = 1
while (stack.length) {
const u = stack[stack.length - 1]
if (edge[u] < cross[u].length) {
const v = cross[u][edge[u]++]
if (!(masks[v] & bit)) continue
if (!order[v]) {
parent[v] = u
order[v] = low[v] = ++time
terminals[v] = masks[v] === bit ? 1 : 0
stack.push(v)
} else if (v !== parent[u]) low[u] = Math.min(low[u], order[v])
} else {
stack.pop()
const p = parent[u]
if (p < 0) continue
low[p] = Math.min(low[p], low[u])
terminals[p] += terminals[u]
// A bridge must be this color if its removal separates two known same-color cells.
if (p !== source && low[u] >= order[p] && terminals[u] > 0 && masks[p] !== bit) {
masks[p] = bit
changed = true
}
}
}
for (const i of indices) {
if (masks[i] & bit && !order[i]) {
masks[i] &= ~bit
changed = true
}
}
}
if (masks.some((mask) => mask === 0)) throw new Error('Empty color domain')
return changed
}
function infer(board: Board, revealed: boolean[]) {
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
do {
propagate(board, revealed, masks)
} while (connectivity(masks))
return masks
}
function solve(board: Board, clues: boolean[]) {
const revealed = clues.slice()
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
while (true) {
propagate(board, revealed, masks)
const next = indices.filter((i) => !revealed[i] && singleton(masks[i]))
if (!next.length) {
if (!connectivity(masks)) return revealed
continue
}
for (const i of next) {
if (colorOf(masks[i]) !== board.answer[i]) throw new Error('Unsound deduction')
revealed[i] = true
}
}
}
return { around, cross, grow, infer, solve }
}
export function generate(seed: number, difficulty: Difficulty): Puzzle {
const level = LEVELS[difficulty]
const random = randomSource(seed)
const engine = createEngine(level.size, level.colors)
const board = engine.grow(random)
const indices = board.answer.map((_, i) => i)
const clues: boolean[] = indices.map(() => false)
for (let color = 0; color < level.colors; color++) {
const cells = shuffle(
indices.filter((i) => board.answer[i] === color),
random,
)
cells.sort((a, b) => board.numbers[b] - board.numbers[a])
clues[cells[0]] = true
}
let solved = engine.solve(board, clues)
while (solved.some((value) => !value)) {
const cells = shuffle(
indices.filter((i) => !solved[i]),
random,
)
cells.sort(
(a, b) =>
engine.around[b].filter((j) => !solved[j]).length -
engine.around[a].filter((j) => !solved[j]).length,
)
clues[cells[0]] = true
solved = engine.solve(board, clues)
}
// Greedy irredundancy under these deduction rules, not a claim of global minimum clue count.
for (const i of shuffle(
indices.filter((i) => clues[i]),
random,
)) {
if (indices.filter((j) => clues[j] && board.answer[j] === board.answer[i]).length <= 1) continue
clues[i] = false
if (engine.solve(board, clues).some((value) => !value)) clues[i] = true
}
const target = Math.ceil(indices.length * level.density)
const pools = Array.from({ length: level.colors }, (_, color) =>
shuffle(
indices.filter((i) => board.answer[i] === color && !clues[i]),
random,
),
)
let total = clues.filter(Boolean).length
while (total < target) {
for (const pool of pools) {
if (pool.length && total < target) {
clues[pool.pop()!] = true
total++
}
}
}
return { ...board, clues, seed, difficulty }
}
@@ -0,0 +1,9 @@
import { type Difficulty, generate } from './engine'
self.onmessage = (event: MessageEvent<{ seed: number; difficulty: Difficulty }>) => {
try {
self.postMessage({ puzzle: generate(event.data.seed, event.data.difficulty) })
} catch {
self.postMessage({ error: true })
}
}
@@ -0,0 +1,93 @@
import { defineMessages } from '@modrinth/ui'
export const messages = defineMessages({
title: { id: 'app.easteregg.color-mine.title', defaultMessage: 'Starlight Mine: Chromatic Realms' },
easy: { id: 'app.easteregg.color-mine.easy', defaultMessage: 'Easy' },
normal: { id: 'app.easteregg.color-mine.normal', defaultMessage: 'Normal' },
hard: { id: 'app.easteregg.color-mine.hard', defaultMessage: 'Hard' },
newGame: { id: 'app.easteregg.color-mine.newGame', defaultMessage: 'New board' },
close: { id: 'app.easteregg.color-mine.close', defaultMessage: 'Close' },
choose: {
id: 'app.easteregg.color-mine.choose',
defaultMessage: 'Click a revealed tile to sample its color, then paint a gray tile.',
},
selected: {
id: 'app.easteregg.color-mine.selected',
defaultMessage: 'Color {color} selected · Click a colored tile to change your brush.',
},
rulesTitle: { id: 'app.easteregg.color-mine.rulesTitle', defaultMessage: 'How to play' },
rules: {
id: 'app.easteregg.color-mine.rules',
defaultMessage:
'Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing.',
},
loading: {
id: 'app.easteregg.color-mine.loading',
defaultMessage: 'Generating regions and checking the deduction path…',
},
generateError: {
id: 'app.easteregg.color-mine.generateError',
defaultMessage: 'Could not generate this board. Try again.',
},
retry: { id: 'app.easteregg.color-mine.retry', defaultMessage: 'Try again' },
progress: {
id: 'app.easteregg.color-mine.progress',
defaultMessage: '{count} / {total} revealed',
},
best: { id: 'app.easteregg.color-mine.best', defaultMessage: 'Best · {time}' },
won: { id: 'app.easteregg.color-mine.won', defaultMessage: 'All colors restored!' },
lost: {
id: 'app.easteregg.color-mine.lost',
defaultMessage:
'Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over.',
},
review: {
id: 'app.easteregg.color-mine.review',
defaultMessage: 'Unrevealed answers are now shown faded for review.',
},
leaveTitle: { id: 'app.easteregg.color-mine.leaveTitle', defaultMessage: 'Leave this board?' },
leaveText: {
id: 'app.easteregg.color-mine.leaveText',
defaultMessage: 'Save to continue later, or discard this unfinished board.',
},
saveLeave: { id: 'app.easteregg.color-mine.saveLeave', defaultMessage: 'Save and leave' },
discard: { id: 'app.easteregg.color-mine.discard', defaultMessage: 'Discard' },
continue: { id: 'app.easteregg.color-mine.continue', defaultMessage: 'Keep playing' },
replaceTitle: {
id: 'app.easteregg.color-mine.replaceTitle',
defaultMessage: 'Start a new board?',
},
replaceText: {
id: 'app.easteregg.color-mine.replaceText',
defaultMessage: 'The current board will be discarded.',
},
savedTitle: {
id: 'app.easteregg.color-mine.savedTitle',
defaultMessage: 'An unfinished board is saved',
},
resume: { id: 'app.easteregg.color-mine.resume', defaultMessage: 'Resume saved game' },
storageError: {
id: 'app.easteregg.color-mine.storageError',
defaultMessage:
'Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it.',
},
recordError: {
id: 'app.easteregg.color-mine.recordError',
defaultMessage: 'Finished, but the best time could not be saved.',
},
zoomIn: { id: 'app.easteregg.color-mine.zoomIn', defaultMessage: 'Zoom in' },
zoomOut: { id: 'app.easteregg.color-mine.zoomOut', defaultMessage: 'Zoom out' },
map: { id: 'app.easteregg.color-mine.map', defaultMessage: 'Board overview. Click to navigate.' },
board: {
id: 'app.easteregg.color-mine.board',
defaultMessage: '{size} by {size} color deduction board',
},
hidden: {
id: 'app.easteregg.color-mine.hidden',
defaultMessage: 'Row {row}, column {column}, unrevealed',
},
revealed: {
id: 'app.easteregg.color-mine.revealed',
defaultMessage: 'Row {row}, column {column}, color {color}, {number} same-color neighbors',
},
})
@@ -0,0 +1,13 @@
/* Semantic colors stay identifiable when the launcher's theme/accent changes. */
.color-mine {
--mine-color-0: var(--color-red);
--mine-color-1: #d7ac37;
--mine-color-2: var(--color-green);
--mine-color-3: var(--color-blue);
--mine-color-4: var(--color-purple);
--mine-color-5: var(--color-orange);
--mine-color-6: #97ba35;
--mine-color-7: #22b6b8;
--mine-color-8: var(--color-pink);
--mine-color-9: #8999ac;
}
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { generate } from './engine.ts'
import {
parseSave,
readBest,
recordBest,
SAVE_KEY,
type SavedGame,
saveGame,
validateResume,
} from './storage.ts'
const puzzle = generate(123, 'easy')
const saved: SavedGame = {
version: 1,
seed: puzzle.seed,
difficulty: puzzle.difficulty,
revealed: puzzle.clues.slice(),
selected: -1,
elapsed: 4321,
zoom: 1,
left: 12,
top: 34,
}
function memoryStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value)
},
removeItem: (key: string) => {
values.delete(key)
},
}
}
test('save round trip keeps board progress, brush, timer and viewport', () => {
const storage = memoryStorage()
saveGame(storage, saved)
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
validateResume(saved, puzzle)
assert.equal(parseSave(null), null)
})
test('malformed and incompatible saves are rejected without overwriting data', () => {
for (const bad of [
null,
{},
{ ...saved, version: 2 },
{ ...saved, difficulty: '__proto__' },
{ ...saved, revealed: [true] },
{ ...saved, elapsed: -1 },
{ ...saved, selected: 15 },
{ ...saved, zoom: 5 },
{ ...saved, seed: 1.2 },
{ ...saved, top: -1 },
{ ...saved, revealed: saved.revealed.map(() => true) },
]) {
assert.throws(() => parseSave(JSON.stringify(bad)))
}
assert.throws(() => parseSave('{invalid'))
assert.throws(() => validateResume({ ...saved, seed: 9 }, puzzle))
assert.throws(() =>
validateResume({ ...saved, revealed: saved.revealed.map(() => false) }, puzzle),
)
const storage = memoryStorage()
saveGame(storage, saved)
assert.throws(() => saveGame(storage, { ...saved, elapsed: -1 }))
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
})
test('storage failures are surfaced so the UI can keep the active board', () => {
assert.throws(() =>
saveGame(
{
...memoryStorage(),
setItem: () => {
throw new Error('Quota exceeded')
},
},
saved,
),
)
})
test('best times are independent per difficulty and only improve', () => {
const storage = memoryStorage()
assert.equal(readBest(storage, 'easy'), null)
assert.equal(recordBest(storage, 'easy', 1000), 1000)
assert.equal(recordBest(storage, 'easy', 2000), 1000)
assert.equal(recordBest(storage, 'easy', 500), 500)
assert.equal(recordBest(storage, 'hard', 9000), 9000)
assert.equal(readBest(storage, 'easy'), 500)
assert.throws(() => recordBest(storage, 'easy', -1))
})
@@ -0,0 +1,93 @@
import { type Difficulty, LEVELS, type Puzzle } from './engine.ts'
export const SAVE_KEY = 'starlight.color-mine.save.v1'
const BEST_KEY = 'starlight.color-mine.best.v1'
export interface SavedGame {
version: 1
seed: number
difficulty: Difficulty
revealed: boolean[]
selected: number
elapsed: number
zoom: number
left: number
top: number
}
type StoragePort = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
const finite = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
export function parseSave(raw: string | null): SavedGame | null {
if (raw === null) return null
const value = JSON.parse(raw) as Partial<SavedGame> | null
if (
!value ||
value.version !== 1 ||
!value.difficulty ||
!Object.keys(LEVELS).includes(value.difficulty)
) {
throw new Error('Invalid saved game')
}
const level = LEVELS[value.difficulty]
if (
!finite(value.seed) ||
!Number.isInteger(value.seed) ||
value.seed < 0 ||
value.seed > 0xffffffff ||
!Array.isArray(value.revealed) ||
value.revealed.length !== level.size ** 2 ||
value.revealed.some((v) => typeof v !== 'boolean') ||
value.revealed.every(Boolean) ||
!finite(value.selected) ||
!Number.isInteger(value.selected) ||
value.selected < -1 ||
value.selected >= level.colors ||
!finite(value.elapsed) ||
value.elapsed < 0 ||
!finite(value.zoom) ||
value.zoom < 0.7 ||
value.zoom > 1.6 ||
!finite(value.left) ||
value.left < 0 ||
!finite(value.top) ||
value.top < 0
)
throw new Error('Invalid saved game')
return value as SavedGame
}
export function validateResume(saved: SavedGame, puzzle: Puzzle) {
if (
saved.seed !== puzzle.seed ||
saved.difficulty !== puzzle.difficulty ||
puzzle.clues.some((clue, i) => clue && !saved.revealed[i]) ||
(saved.selected >= 0 &&
!saved.revealed.some((open, i) => open && puzzle.answer[i] === saved.selected))
) {
throw new Error('Saved game does not match puzzle')
}
}
export function saveGame(storage: StoragePort, saved: SavedGame) {
const raw = JSON.stringify(saved)
parseSave(raw)
storage.setItem(SAVE_KEY, raw)
}
export function readBest(storage: StoragePort, difficulty: Difficulty): number | null {
const raw = storage.getItem(BEST_KEY)
if (!raw) return null
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object') return null
const time = (value as Record<string, unknown>)[difficulty]
return finite(time) && time >= 0 ? time : null
}
export function recordBest(storage: StoragePort, difficulty: Difficulty, elapsed: number) {
if (!finite(elapsed) || elapsed < 0) throw new Error('Invalid time')
const best = Object.fromEntries(
Object.keys(LEVELS).map((key) => [key, readBest(storage, key as Difficulty)]),
)
best[difficulty] = Math.min(best[difficulty] ?? Infinity, elapsed)
storage.setItem(BEST_KEY, JSON.stringify(best))
return best[difficulty]
}
@@ -14,7 +14,6 @@ import { computed, ref } from 'vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance' import { list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts' import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
@@ -91,7 +90,6 @@ defineExpose({
instances.value = instanceValues instances.value = instanceValues
modal.value.show() modal.value.show()
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
}, },
}) })
@@ -102,11 +100,6 @@ async function addServer(instance) {
instance.added = true instance.added = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] }) await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
trackEvent('AddServerToInstance', {
server_name: serverName.value,
instance_name: instance.name,
source: 'AddServerToInstanceModal',
})
} catch (err) { } catch (err) {
handleError(err) handleError(err)
} }
@@ -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>
@@ -16,9 +16,10 @@ import { basename, dirname, join } from '@tauri-apps/api/path'
import { computed, type Ref, ref, watch } from 'vue' import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import InstancePlayerSettings from '@/components/instance/InstancePlayerSettings.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install' import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance' import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings' import { injectInstanceSettings } from '@/providers/instance-settings'
@@ -49,10 +50,6 @@ const installing = computed(() => instance.value.install_stage !== 'installed')
async function duplicateInstance() { async function duplicateInstance() {
await install_duplicate_instance(instance.value.id).catch(handleError) await install_duplicate_instance(instance.value.id).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
} }
function formatReleaseChannelLabel(channel: ReleaseChannel) { function formatReleaseChannelLabel(channel: ReleaseChannel) {
@@ -104,7 +101,6 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
async function resetIcon() { async function resetIcon() {
icon.value = undefined icon.value = undefined
await edit_icon(instance.value.id, null).catch(handleError) await edit_icon(instance.value.id, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
} }
async function setIcon() { async function setIcon() {
@@ -116,7 +112,6 @@ async function setIcon() {
icon.value = picked.path icon.value = picked.path
try { try {
await edit_icon(instance.value.id, picked.path) await edit_icon(instance.value.id, picked.path)
trackEvent('InstanceSetIcon')
} catch (error) { } catch (error) {
icon.value = previousIcon icon.value = previousIcon
handleError(error) handleError(error)
@@ -236,7 +231,7 @@ async function setGameDirMode(mode: GameDirMode) {
} }
const editInstanceObject = computed(() => ({ const editInstanceObject = computed(() => ({
name: title.value.trim().substring(0, 32) ?? 'Instance', name: title.value.trim(),
})) }))
watch( watch(
@@ -253,10 +248,6 @@ async function removeInstance() {
removing.value = true removing.value = true
const path = instance.value.id const path = instance.value.id
trackEvent('InstanceRemove', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
await router.push({ path: '/' }) await router.push({ path: '/' })
await remove(path).catch(handleError) await remove(path).catch(handleError)
@@ -390,6 +381,8 @@ const messages = defineMessages({
</script> </script>
<template> <template>
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
<InstancePlayerSettings :instance-id="instance.id" />
<ConfirmDeleteInstanceModal <ConfirmDeleteInstanceModal
ref="deleteConfirmModal" ref="deleteConfirmModal"
:symlink-target="instance.symlink_target" :symlink-target="instance.symlink_target"
@@ -448,7 +441,6 @@ const messages = defineMessages({
id="instance-name" id="instance-name"
v-model="title" v-model="title"
autocomplete="off" autocomplete="off"
:maxlength="80"
wrapper-class="flex-grow" wrapper-class="flex-grow"
/> />
</div> </div>
@@ -22,7 +22,6 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue' import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache' import { get_project_versions, get_version } from '@/helpers/cache'
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge' import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
import { import {
@@ -333,10 +332,6 @@ provideInstallationSettings({
afterSave: async () => { afterSave: async () => {
debug('afterSave: installing', { instanceId: instance.value.id }) debug('afterSave: installing', { instanceId: instance.value.id })
await install_existing_instance(instance.value.id, false).catch(handleError) await install_existing_instance(instance.value.id, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('afterSave: done') debug('afterSave: done')
}, },
@@ -345,40 +340,27 @@ provideInstallationSettings({
repairing.value = true repairing.value = true
await install_existing_instance(instance.value.id, true).catch(handleError) await install_existing_instance(instance.value.id, true).catch(handleError)
repairing.value = false repairing.value = false
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('repair: done') debug('repair: done')
}, },
async reinstallModpack() { async reinstallModpack() {
debug('reinstallModpack: called', { instanceId: instance.value.id }) debug('reinstallModpack: called', { instanceId: instance.value.id })
reinstalling.value = true reinstalling.value = true
let shouldTrack = false
try { try {
if (isImportedModpack.value) { if (isImportedModpack.value) {
shouldTrack = await installLocalModpackFromPicker() await installLocalModpackFromPicker()
} else if (isCurseForgeLinkedModpack.value) { } else if (isCurseForgeLinkedModpack.value) {
const fileId = Number(instance.value.link?.version_id) const fileId = Number(instance.value.link?.version_id)
if (!Number.isFinite(fileId)) { if (!Number.isFinite(fileId)) {
throw new Error('Invalid CurseForge file ID') throw new Error('Invalid CurseForge file ID')
} }
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError) await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
shouldTrack = true
} else { } else {
await update_repair_modrinth(instance.value.id).catch(handleError) await update_repair_modrinth(instance.value.id).catch(handleError)
shouldTrack = true
} }
} finally { } finally {
reinstalling.value = false reinstalling.value = false
} }
if (shouldTrack) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
debug('reinstallModpack: done') debug('reinstallModpack: done')
}, },
@@ -386,13 +368,7 @@ provideInstallationSettings({
debug('swapModpack: called', { instanceId: instance.value.id }) debug('swapModpack: called', { instanceId: instance.value.id })
reinstalling.value = true reinstalling.value = true
try { try {
const installed = await installLocalModpackFromPicker() await installLocalModpackFromPicker()
if (installed) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
} finally { } finally {
reinstalling.value = false reinstalling.value = false
} }
@@ -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>
@@ -61,6 +61,7 @@ import {
ButtonStyled, ButtonStyled,
commonMessages, commonMessages,
defineMessages, defineMessages,
injectNotificationManager,
NewModal, NewModal,
RadioButtons, RadioButtons,
StyledInput, StyledInput,
@@ -72,6 +73,7 @@ import { edit, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const props = defineProps<{ const props = defineProps<{
instanceIds: string[] instanceIds: string[]
@@ -148,7 +150,7 @@ async function deleteGroup(group: string) {
for (const instance of allInstances.value) { for (const instance of allInstances.value) {
if (instance.groups.includes(group)) { if (instance.groups.includes(group)) {
const newGroups = instance.groups.filter((g) => g !== 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 instance.groups = newGroups
} }
} }
@@ -167,7 +169,7 @@ async function confirm() {
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : [] const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
for (const instanceId of props.instanceIds) { for (const instanceId of props.instanceIds) {
await edit(instanceId, { groups }).catch(() => {}) await edit(instanceId, { groups }).catch(handleError)
} }
emit('applied') emit('applied')
@@ -67,6 +67,7 @@ withDefaults(
}>(), }>(),
{ {
count: 1, count: 1,
symlinkTarget: null,
}, },
) )
@@ -127,7 +127,7 @@ interface IconBackground {
style: Record<string, string> style: Record<string, string>
} }
function background( function makeBackground(
id: string, id: string,
name: MessageDescriptor, name: MessageDescriptor,
top: string, top: string,
@@ -171,16 +171,16 @@ const backgrounds = [
backgroundSize: '16px 16px', backgroundSize: '16px 16px',
}, },
} satisfies IconBackground, } satisfies IconBackground,
background('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'), makeBackground('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
background('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'), makeBackground('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
background('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'), makeBackground('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
background('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'), makeBackground('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
background('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'), makeBackground('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
background('nether', backgroundNames.nether, '#ef5a46', '#6d1717'), makeBackground('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
background('slime', backgroundNames.slime, '#b6ee55', '#44972f'), makeBackground('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
background('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'), makeBackground('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
background('stone', backgroundNames.stone, '#aeb5bd', '#525b66'), makeBackground('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
background('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'), makeBackground('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
] ]
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -7,6 +7,7 @@ import {
FileArchiveIcon, FileArchiveIcon,
InfoIcon, InfoIcon,
MonitorIcon, MonitorIcon,
RefreshCwIcon,
WrenchIcon, WrenchIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
@@ -20,8 +21,10 @@ import type { PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref, watch } from 'vue' 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 CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.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 HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue' import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue' import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
@@ -52,8 +55,9 @@ let serverMetadataGeneration = 0
const instanceRef = computed(() => props.instance) const instanceRef = computed(() => props.instance)
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null) const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function hide() { function hide(onAfterClose?: () => void) {
tabbedModal.value?.hide() tabbedModal.value?.hide()
if (onAfterClose) nextTick(onAfterClose)
} }
provideInstanceSettings({ provideInstanceSettings({
@@ -105,6 +109,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: WrenchIcon, icon: WrenchIcon,
content: InstallationSettings, 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. // Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
name: defineMessage({ name: defineMessage({
@@ -138,6 +150,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: CodeIcon, icon: CodeIcon,
content: HooksSettings, content: HooksSettings,
}, },
{
name: defineMessage({
id: 'instance.settings.tabs.sync',
defaultMessage: 'Sync',
}),
icon: RefreshCwIcon,
content: SyncSettings,
},
]) ])
function getSupportedModpackLoaders() { function getSupportedModpackLoaders() {
@@ -1,145 +0,0 @@
<script setup lang="ts">
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NewModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
const emit = defineEmits<{
saved: [privacy: PrivacySettings]
}>()
const CONSENT_VERSION = 1
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const telemetry = ref(true)
const discordRpc = ref(true)
const saving = ref(false)
const messages = defineMessages({
title: {
id: 'app.privacy-consent.title',
defaultMessage: 'Privacy & security',
},
intro: {
id: 'app.privacy-consent.intro',
defaultMessage:
'Choose what Starlight may send or display. Nothing is sent until you confirm these choices.',
},
telemetry: {
id: 'app.privacy-consent.telemetry',
defaultMessage: 'Allow anonymous telemetry',
},
telemetryDescription: {
id: 'app.privacy-consent.telemetry-description',
defaultMessage:
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
},
discordRpc: {
id: 'app.privacy-consent.discord-rpc',
defaultMessage: 'Discord Rich Presence',
},
discordRpcDescription: {
id: 'app.privacy-consent.discord-rpc-description',
defaultMessage:
'Shows your current launcher or game activity in Discord when Discord is running.',
},
privacyPolicy: {
id: 'app.privacy-consent.privacy-policy',
defaultMessage: 'Read the privacy policy',
},
continue: {
id: 'app.privacy-consent.continue',
defaultMessage: 'Save and continue',
},
})
function show(current: PrivacySettings) {
telemetry.value = true
discordRpc.value = current.discord_rpc
modal.value?.show()
}
async function save() {
if (saving.value) return
saving.value = true
try {
const privacy = await savePrivacySettings({
telemetry: telemetry.value,
discord_rpc: discordRpc.value,
consent_version: CONSENT_VERSION,
})
modal.value?.hide()
emit('saved', privacy)
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
<div class="flex flex-col gap-6">
<div class="flex items-start gap-3">
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
<p class="m-0 leading-relaxed text-primary">
{{ formatMessage(messages.intro) }}
</p>
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-telemetry" class="font-semibold text-contrast">
{{ formatMessage(messages.telemetry) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.telemetryDescription) }}
</p>
</div>
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-discord-rpc" class="font-semibold text-contrast">
{{ formatMessage(messages.discordRpc) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.discordRpcDescription) }}
</p>
</div>
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
</div>
</div>
<template #actions>
<div class="flex items-center justify-between gap-4">
<ButtonStyled type="transparent">
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
<ExternalIcon />
{{ formatMessage(messages.privacyPolicy) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="saving" @click="save">
<SpinnerIcon v-if="saving" class="animate-spin" />
{{ formatMessage(messages.continue) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -35,7 +35,7 @@ const progressWidth = computed(() => `${Math.min(100, (props.current / props.tot
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2> <h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
</div> </div>
<ButtonStyled circular type="transparent"> <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 /> <XIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -170,7 +170,7 @@ const {
top: auto !important; top: auto !important;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0 !important; left: var(--left-bar-width, 4rem) !important;
z-index: 2; z-index: 2;
width: auto; width: auto;
min-height: 0; min-height: 0;
@@ -110,7 +110,8 @@ const { formatMessage } = useVIntl()
color: var(--color-contrast); color: var(--color-contrast);
font-size: 4.5rem; font-size: 4.5rem;
font-weight: 800; font-weight: 800;
line-height: 1; // Keep descenders inside the line box clipped by the wordmark reveal animation.
line-height: normal;
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both; animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
@@ -41,24 +41,6 @@ export const onboardingMessages = defineMessages({
defaultMessage: 'Your last next launcher.', defaultMessage: 'Your last next launcher.',
}, },
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' }, start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
homeWidgetsTitle: {
id: 'app.onboarding.home-widgets.title',
defaultMessage: 'Your Home, your layout',
},
homeWidgetsDescription: {
id: 'app.onboarding.home-widgets.description',
defaultMessage:
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
},
homeCustomizeTitle: {
id: 'app.onboarding.home-customize.title',
defaultMessage: 'Arrange it your way',
},
homeCustomizeDescription: {
id: 'app.onboarding.home-customize.description',
defaultMessage:
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
},
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' }, discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
discoverDescription: { discoverDescription: {
id: 'app.onboarding.discover.description', id: 'app.onboarding.discover.description',
@@ -90,12 +72,12 @@ export const onboardingMessages = defineMessages({
}, },
homeLayoutTitle: { homeLayoutTitle: {
id: 'app.onboarding.home-layout.title', id: 'app.onboarding.home-layout.title',
defaultMessage: 'Change the amount of detail', defaultMessage: 'Switch your home',
}, },
homeLayoutDescription: { homeLayoutDescription: {
id: 'app.onboarding.home-layout.description', id: 'app.onboarding.home-layout.description',
defaultMessage: defaultMessage:
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.', 'Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher.',
}, },
continueArea: { continueArea: {
id: 'app.onboarding.action.continue-area', id: 'app.onboarding.action.continue-area',
@@ -105,7 +87,7 @@ export const onboardingMessages = defineMessages({
skinsDescription: { skinsDescription: {
id: 'app.onboarding.skins.description', id: 'app.onboarding.skins.description',
defaultMessage: defaultMessage:
'Keep your Minecraft skins together. Signing in can wait until you feel like it.', 'Choose a skin site player or Minecraft account, then preview and apply the skins available to that profile.',
}, },
clickSkins: { clickSkins: {
id: 'app.onboarding.action.click-skins', id: 'app.onboarding.action.click-skins',
@@ -114,16 +96,8 @@ export const onboardingMessages = defineMessages({
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' }, skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
skinsPageDescription: { skinsPageDescription: {
id: 'app.onboarding.skins-page.description', id: 'app.onboarding.skins-page.description',
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
},
accountTitle: {
id: 'app.onboarding.account.title',
defaultMessage: 'Accounts, on your schedule',
},
accountDescription: {
id: 'app.onboarding.account.description',
defaultMessage: defaultMessage:
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.', 'Select a profile, preview its available skins, and apply changes when that account supports skin management.',
}, },
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' }, downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
downloadsDescription: { downloadsDescription: {
@@ -160,35 +134,6 @@ export const onboardingMessages = defineMessages({
defaultMessage: defaultMessage:
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.', 'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
}, },
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
languageDescription: {
id: 'app.onboarding.language.description',
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
},
translationTitle: {
id: 'app.onboarding.translation.title',
defaultMessage: 'Translation, the Starlight way',
},
translationDescription: {
id: 'app.onboarding.translation.description',
defaultMessage:
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
},
aiTitle: {
id: 'app.onboarding.ai.title',
defaultMessage: 'Bring your own AI provider',
},
aiDescription: {
id: 'app.onboarding.ai.description',
defaultMessage:
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
},
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
javaDescription: {
id: 'app.onboarding.java.description',
defaultMessage:
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
},
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' }, defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
defaultsDescription: { defaultsDescription: {
id: 'app.onboarding.defaults.description', id: 'app.onboarding.defaults.description',
@@ -204,20 +149,6 @@ export const onboardingMessages = defineMessages({
defaultMessage: defaultMessage:
'Choose how content downloads and installs, from download sources to safety checks.', 'Choose how content downloads and installs, from download sources to safety checks.',
}, },
privacyTitle: {
id: 'app.onboarding.privacy.title',
defaultMessage: 'Your data, your call',
},
privacyDescription: {
id: 'app.onboarding.privacy.description',
defaultMessage:
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
},
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
updatesDescription: {
id: 'app.onboarding.updates.description',
defaultMessage: 'Choose when Starlight checks for updates and whether it installs them for you.',
},
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' }, clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' }, libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
libraryDescription: { libraryDescription: {
@@ -236,7 +167,7 @@ export const onboardingMessages = defineMessages({
libraryPageDescription: { libraryPageDescription: {
id: 'app.onboarding.library-page.description', id: 'app.onboarding.library-page.description',
defaultMessage: defaultMessage:
'Filter by modpack, server, or custom setup, then open any instance to manage it.', 'Switch between all instances, modpacks, and custom setups, then open any instance to manage it.',
}, },
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' }, createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
createDescription: { createDescription: {
@@ -248,6 +179,15 @@ export const onboardingMessages = defineMessages({
defaultMessage: 'Click Create new instance to continue', defaultMessage: 'Click Create new instance to continue',
}, },
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' }, creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
instanceModeTitle: {
id: 'app.onboarding.instance-mode.title',
defaultMessage: 'Choose your instance type',
},
instanceModeDescription: {
id: 'app.onboarding.instance-mode.description',
defaultMessage:
'StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player.',
},
creationDescription: { creationDescription: {
id: 'app.onboarding.creation.description', id: 'app.onboarding.creation.description',
defaultMessage: defaultMessage:
@@ -302,7 +242,7 @@ export const onboardingMessages = defineMessages({
instanceActionsDescription: { instanceActionsDescription: {
id: 'app.onboarding.instance-actions.description', id: 'app.onboarding.instance-actions.description',
defaultMessage: defaultMessage:
'Launch, stop, repair, configure, export, or open the instance from its header.', 'Launch or configure this instance here. You can launch another window while it is running; use the top bar to stop an individual window. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
}, },
instanceTabsTitle: { instanceTabsTitle: {
id: 'app.onboarding.instance-tabs.title', id: 'app.onboarding.instance-tabs.title',
@@ -312,6 +252,42 @@ export const onboardingMessages = defineMessages({
id: 'app.onboarding.instance-tabs.description', id: 'app.onboarding.instance-tabs.description',
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.', 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: { labTitle: {
id: 'app.onboarding.lab.title', id: 'app.onboarding.lab.title',
defaultMessage: 'Useful tools, built in', defaultMessage: 'Useful tools, built in',
@@ -319,7 +295,7 @@ export const onboardingMessages = defineMessages({
labDescription: { labDescription: {
id: 'app.onboarding.lab.description', id: 'app.onboarding.lab.description',
defaultMessage: defaultMessage:
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.', 'The Lab keeps Minecraft creation, world, and maintenance tools inside the launcher.',
}, },
clickLab: { clickLab: {
id: 'app.onboarding.action.click-lab', id: 'app.onboarding.action.click-lab',
@@ -332,66 +308,52 @@ export const onboardingMessages = defineMessages({
labToolsDescription: { labToolsDescription: {
id: 'app.onboarding.lab-tools.description', id: 'app.onboarding.lab-tools.description',
defaultMessage: defaultMessage:
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.', 'Create and edit skins, generate formatted text and recipes, explore seeds, inspect schematics, and translate mods locally.',
},
openGradientText: {
id: 'app.onboarding.action.open-gradient-text',
defaultMessage: 'Open Gradient text generator to continue',
},
labEditorTitle: {
id: 'app.onboarding.lab-editor.title',
defaultMessage: 'Build and copy in one place',
},
labEditorDescription: {
id: 'app.onboarding.lab-editor.description',
defaultMessage:
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
},
labSeedMapTitle: {
id: 'app.onboarding.lab-seed-map.title',
defaultMessage: 'Find a world before you load it',
},
labSeedMapDescription: {
id: 'app.onboarding.lab-seed-map.description',
defaultMessage:
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
},
returnToLab: {
id: 'app.onboarding.action.return-lab',
defaultMessage: 'Click Lab to continue',
},
openSeedMap: {
id: 'app.onboarding.action.open-seed-map',
defaultMessage: 'Open Seed map to continue',
},
openSchematicWorkshop: {
id: 'app.onboarding.action.open-schematic-workshop',
defaultMessage: 'Open Schematic workshop to continue',
},
openRecipeGenerator: {
id: 'app.onboarding.action.open-recipe-generator',
defaultMessage: 'Open Recipe generator to continue',
},
labRecipeGeneratorTitle: {
id: 'app.onboarding.lab-recipe-generator.title',
defaultMessage: 'Craft data pack recipes',
},
labRecipeGeneratorDescription: {
id: 'app.onboarding.lab-recipe-generator.description',
defaultMessage:
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
},
labSchematicTitle: {
id: 'app.onboarding.lab-schematic.title',
defaultMessage: 'Inspect a build before placing it',
},
labSchematicDescription: {
id: 'app.onboarding.lab-schematic.description',
defaultMessage:
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
}, },
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' }, skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' }, 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 = ( const step = (
@@ -447,32 +409,33 @@ const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescri
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = { export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
main: [ main: [
inspect(
'home-intro',
'home-widget-grid',
onboardingMessages.homeIntroTitle,
onboardingMessages.homeIntroDescription,
),
inspect(
'account-login',
'sidebar-accounts',
onboardingMessages.accountLoginTitle,
onboardingMessages.accountLoginDescription,
),
inspect(
'announcement',
'home-announcement',
onboardingMessages.announcementTitle,
onboardingMessages.announcementDescription,
),
step( step(
'welcome', 'create-starlight',
'manual', 'navigate',
copy( copy(
onboardingMessages.welcomeTitle, onboardingMessages.createStarlightTitle,
onboardingMessages.welcomeDescription, onboardingMessages.createStarlightDescription,
onboardingMessages.start, onboardingMessages.clickCreate,
), ),
), control('create-instance', '/create'),
inspect(
'home-widget-grid',
'home-widget-grid',
onboardingMessages.homeWidgetsTitle,
onboardingMessages.homeWidgetsDescription,
),
inspect(
'home-widget-customize',
'home-widget-customize',
onboardingMessages.homeCustomizeTitle,
onboardingMessages.homeCustomizeDescription,
),
inspect(
'home-layout-switch',
'home-layout-switch',
onboardingMessages.homeLayoutTitle,
onboardingMessages.homeLayoutDescription,
), ),
step( step(
'discover-navigation', 'discover-navigation',
@@ -482,7 +445,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.discoverDescription, onboardingMessages.discoverDescription,
onboardingMessages.clickDiscover, onboardingMessages.clickDiscover,
), ),
control('nav-discover', '/browse/modpack'), control('nav-discover', '/browse'),
), ),
inspect( inspect(
'discover-content', 'discover-content',
@@ -490,22 +453,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.browseTitle, onboardingMessages.browseTitle,
onboardingMessages.browseDescription, 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( step(
'skins-navigation', 'skins-navigation',
'navigate', 'navigate',
@@ -523,14 +470,20 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.skinsPageDescription, onboardingMessages.skinsPageDescription,
), ),
step( step(
'account', 'library-navigation',
'inspect', 'navigate',
copy( copy(
onboardingMessages.accountTitle, onboardingMessages.libraryTitle,
onboardingMessages.accountDescription, onboardingMessages.libraryDescription,
onboardingMessages.continueArea, onboardingMessages.clickLibrary,
), ),
control('account-entry'), control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
), ),
step( step(
'lab-navigation', 'lab-navigation',
@@ -548,90 +501,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.labToolsTitle, onboardingMessages.labToolsTitle,
onboardingMessages.labToolsDescription, onboardingMessages.labToolsDescription,
), ),
step(
'lab-gradient-text-navigation',
'navigate',
copy(
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
onboardingMessages.openGradientText,
),
control('lab-gradient-text-card', '/lab/gradient-text'),
),
inspect(
'lab-gradient-text-editor',
'lab-gradient-text-editor',
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
),
step(
'lab-return-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-seed-map-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.openSeedMap,
),
control('lab-seed-map-card', '/lab/seed-map'),
),
inspect(
'lab-seed-map-workspace',
'seed-map-workspace',
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
),
step(
'lab-return-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.openSchematicWorkshop,
),
control('lab-schematic-preview-card', '/lab/schematic-preview'),
),
inspect(
'lab-schematic-workspace',
'schematic-preview-workspace',
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
),
step(
'lab-recipe-generator-navigation',
'navigate',
copy(
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
onboardingMessages.openRecipeGenerator,
),
control('lab-recipe-generator-card', '/lab/recipe-generator'),
),
inspect(
'lab-recipe-generator-workspace',
'recipe-generator-workspace',
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
),
step( step(
'downloads-navigation', 'downloads-navigation',
'navigate', 'navigate',
@@ -650,7 +519,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
), ),
step( step(
'settings-navigation', 'settings-navigation',
'activate', 'navigate',
copy( copy(
onboardingMessages.settingsTitle, onboardingMessages.settingsTitle,
onboardingMessages.settingsDescription, onboardingMessages.settingsDescription,
@@ -658,88 +527,15 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
), ),
control('nav-settings', '/settings'), 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( step(
'library-navigation', 'settings-updates',
'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'),
),
step(
'creation-flow',
'activate', 'activate',
copy( copy(
onboardingMessages.creationTitle, onboardingMessages.settingsUpdatesTitle,
onboardingMessages.creationDescription, onboardingMessages.settingsUpdatesDescription,
onboardingMessages.clickCreationMethod, onboardingMessages.clickTab,
), ),
{ control('settings-tab-updates'),
targetId: 'creation-methods',
branchByTarget: {
'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' },
), ),
], ],
instance: [ instance: [
@@ -749,15 +545,29 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.instanceActionsTitle, onboardingMessages.instanceActionsTitle,
onboardingMessages.instanceActionsDescription, onboardingMessages.instanceActionsDescription,
), ),
step( inspect(
'instance-tabs', 'instance-content-tab',
'inspect', 'instance-tab-content',
copy( onboardingMessages.instanceContentTabTitle,
onboardingMessages.instanceTabsTitle, onboardingMessages.instanceContentTabDescription,
onboardingMessages.instanceTabsDescription, ),
onboardingMessages.finishArea, inspect(
), 'instance-files-tab',
{ targetId: 'instance-tabs' }, '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) { function scheduleMissingTargetRetry(stepId: string) {
if (targetRetryCount >= missingTargetRetryLimit) { if (targetRetryCount >= missingTargetRetryLimit) {
// 超过重试上限:inspect 步(介绍性步骤)不自动跳过,
// 保持无锚点状态,让气泡停靠底部等用户手动继续。
if (step.value.interaction === 'inspect') {
targetElement.value = undefined
targetRect.value = null
clearModalReservation()
return
}
void advance() void advance()
return return
} }
@@ -271,8 +279,11 @@ export function useOnboardingTour(
function handleDocumentClick(event: MouseEvent) { function handleDocumentClick(event: MouseEvent) {
if (!visible.value) return if (!visible.value) return
const clickedElement = event.target instanceof Element ? event.target : null 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') { if (step.value.interaction === 'inspect') {
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation() event.stopImmediatePropagation()
@@ -280,11 +291,14 @@ export function useOnboardingTour(
return return
} }
// 非 inspect 步:点在导览 UI 自身时不处理
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
if (clickedElement && handleBranchClick(clickedElement)) return if (clickedElement && handleBranchClick(clickedElement)) return
if (!targetElement.value?.contains(event.target as Node)) 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 waitingForRoute.value = true
return return
} }
@@ -301,7 +315,7 @@ export function useOnboardingTour(
() => route.path, () => route.path,
(path) => { (path) => {
if (!waitingForRoute.value || !step.value.expectedPath) return if (!waitingForRoute.value || !step.value.expectedPath) return
if (path !== step.value.expectedPath) return if (!path.startsWith(step.value.expectedPath)) return
waitingForRoute.value = false waitingForRoute.value = false
void advance() 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,
}
}
@@ -1,20 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { ChevronDownIcon, ExternalIcon, ScaleIcon, UsersIcon } from '@modrinth/assets'
ChevronDownIcon,
ExternalIcon,
ScaleIcon,
UsersIcon,
} from '@modrinth/assets'
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui' import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue' import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue' import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue' import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
import { AxolotlBrandConfig } from '@/config' import { AxolotlBrandConfig } from '@/config'
import { contributors, type TeamMember, teamMembers } from '@/data/about'
import { contributors, teamMembers, type TeamMember } from '@/data/about'
import AboutScene from '../AboutScene.vue' import AboutScene from '../AboutScene.vue'
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences' import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
@@ -22,9 +16,9 @@ import { type AboutMemberExperience, getAboutMemberExperience } from './about-me
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const version = await getVersion() const version = await getVersion()
const experienceHost = ref<HTMLElement>() const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<AboutMemberExperience>() const activeMemberExperience = shallowRef<Extract<AboutMemberExperience, { kind: 'scene' }>>()
const pressingMemberName = ref<string>() const pressingMemberName = ref<string>()
let longPressTimer: ReturnType<typeof window.setTimeout> | undefined let longPressTimer: number | undefined
let pressStart = { x: 0, y: 0 } let pressStart = { x: 0, y: 0 }
let suppressNextMemberClick = false let suppressNextMemberClick = false
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding') const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
@@ -46,9 +40,13 @@ function startMemberLongPress(member: TeamMember, event: PointerEvent) {
pressStart = { x: event.clientX, y: event.clientY } pressStart = { x: event.clientX, y: event.clientY }
pressingMemberName.value = member.name pressingMemberName.value = member.name
longPressTimer = window.setTimeout(async () => { longPressTimer = window.setTimeout(async () => {
activeMemberExperience.value = experience
suppressNextMemberClick = true suppressNextMemberClick = true
cancelMemberLongPress() cancelMemberLongPress()
if (experience.kind === 'color-mine') {
openColorMine()
return
}
activeMemberExperience.value = experience
await nextTick() await nextTick()
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' }) experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, experience.longPressDuration) }, experience.longPressDuration)
@@ -78,6 +76,24 @@ function closeMemberExperience() {
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null) const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null)
const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null) const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null)
const ColorMineModal = defineAsyncComponent(
() => import('@/components/ui/easteregg/color-mine/ColorMineModal.vue'),
)
const colorMineModal = ref<{ show: () => void }>()
const colorMineMounted = ref(false)
const colorMinePending = ref(false)
function openColorMine() {
colorMineMounted.value = true
if (colorMineModal.value) colorMineModal.value.show()
else colorMinePending.value = true
}
function colorMineReady() {
if (!colorMinePending.value) return
colorMinePending.value = false
colorMineModal.value?.show()
}
let typedBuffer = '' let typedBuffer = ''
const secretCodes = ['starlight'] const secretCodes = ['starlight']
@@ -166,7 +182,8 @@ const messages = defineMessages({
}, },
attribution: { attribution: {
id: 'app.settings.about.attribution', id: 'app.settings.about.attribution',
defaultMessage: 'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.', defaultMessage:
'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.',
}, },
notAffiliated: { notAffiliated: {
id: 'app.settings.about.not-affiliated', id: 'app.settings.about.not-affiliated',
@@ -194,7 +211,6 @@ const messages = defineMessages({
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}', defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
}, },
}) })
</script> </script>
<template> <template>
@@ -203,13 +219,13 @@ const messages = defineMessages({
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<div <div
ref="experienceHost" ref="experienceHost"
class="relative m-0 w-full overflow-hidden h-64 rounded-xl" class="relative m-0 aspect-[3/1] w-full overflow-hidden rounded-xl"
style=" style="
mask-image: linear-gradient(to bottom, black 97%, transparent 100%); mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
" "
> >
<AboutScene /> <AboutScene :paused="Boolean(activeMemberExperience)" />
<component <component
:is="activeMemberExperience?.component" :is="activeMemberExperience?.component"
v-if="activeMemberExperience" v-if="activeMemberExperience"
@@ -237,7 +253,6 @@ const messages = defineMessages({
</p> </p>
</section> </section>
<section> <section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast"> <h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<ScaleIcon class="size-5 text-secondary" /> <ScaleIcon class="size-5 text-secondary" />
@@ -295,12 +310,16 @@ const messages = defineMessages({
target="_blank" target="_blank"
rel="noopener noreferrer" 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" class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
:class="{ 'member-card-holding': pressingMemberName === member.name }"
@pointerdown="startMemberLongPress(member, $event)" @pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress" @pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress" @pointerup="cancelMemberLongPress"
@pointercancel="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress" @pointerleave="cancelMemberLongPress"
@blur="cancelMemberLongPress"
@click="handleMemberClick" @click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)" @contextmenu="handleMemberContextMenu(member, $event)"
@dragstart.prevent
> >
<Avatar <Avatar
:src="member.avatarUrl" :src="member.avatarUrl"
@@ -360,6 +379,7 @@ const messages = defineMessages({
</div> </div>
<EasterEggGameModal ref="gameModal" /> <EasterEggGameModal ref="gameModal" />
<ColorMineModal v-if="colorMineMounted" ref="colorMineModal" @vue:mounted="colorMineReady" />
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" /> <EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
</template> </template>
@@ -381,6 +401,11 @@ const messages = defineMessages({
padding: var(--gap-lg); padding: var(--gap-lg);
} }
.member-card-holding {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.about-page :deep(.rounded-xl.bg-surface-4) { .about-page :deep(.rounded-xl.bg-surface-4) {
border: 1px solid border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent)); var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
@@ -22,10 +22,11 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { appDataDir, join } from '@tauri-apps/api/path' import { appDataDir, join } from '@tauri-apps/api/path'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs' 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 { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils' import { getOS } from '@/helpers/utils'
import { setUiScale, uiScale } from '@/helpers/ui-scale'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import { import {
type AccentColor, type AccentColor,
@@ -40,6 +41,19 @@ import {
parseCustomAccentColor, parseCustomAccentColor,
} from '@/store/theme.ts' } 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 SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue' import SettingsSection from './SettingsSection.vue'
@@ -209,6 +223,15 @@ const messages = defineMessages({
defaultMessage: defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.', '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: { pageTransitionsTitle: {
id: 'app.appearance-settings.page-transitions.title', id: 'app.appearance-settings.page-transitions.title',
defaultMessage: 'Page transition animations', defaultMessage: 'Page transition animations',
@@ -353,6 +376,63 @@ const messages = defineMessages({
defaultMessage: defaultMessage:
'Hide the downloads button in the sidebar when there are no active download tasks.', '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()) const os = ref(await getOS())
@@ -363,6 +443,84 @@ const customBackgroundPreview = computed(() =>
: null, : 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<{ const accentColorOptions: Array<{
value: AccentColor value: AccentColor
color: string color: string
@@ -473,6 +631,8 @@ watch(
settings.value.transparent_background_blur, settings.value.transparent_background_blur,
settings.value.sidebar_instance_count, settings.value.sidebar_instance_count,
settings.value.close_behavior, settings.value.close_behavior,
settings.value.ui_font,
settings.value.mono_font,
] as const, ] as const,
([ ([
path, path,
@@ -483,6 +643,8 @@ watch(
transparentBlur, transparentBlur,
sidebarInstanceCount, sidebarInstanceCount,
closeBehavior, closeBehavior,
uiFont,
monoFont,
]) => { ]) => {
themeStore.customBackgroundPath = path themeStore.customBackgroundPath = path
themeStore.customBackgroundBlur = blur themeStore.customBackgroundBlur = blur
@@ -493,10 +655,19 @@ watch(
themeStore.setTransparentBackgroundClass() themeStore.setTransparentBackgroundClass()
themeStore.sidebarInstanceCount = sidebarInstanceCount themeStore.sidebarInstanceCount = sidebarInstanceCount
themeStore.closeBehavior = closeBehavior as CloseBehavior themeStore.closeBehavior = closeBehavior as CloseBehavior
themeStore.uiFont = uiFont
themeStore.monoFont = monoFont
themeStore.setUiFont()
themeStore.setMonoFont()
}, },
{ immediate: true }, { immediate: true },
) )
onMounted(() => {
if (props.scope === 'interface') {
void loadSystemFonts()
}
})
watch( watch(
settings, settings,
async () => { async () => {
@@ -1020,6 +1191,43 @@ watch(
/> />
</template> </template>
</SettingsRow> </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> <SettingsRow>
<template #label> <template #label>
<span id="settings-target-appearance-page-transitions" tabindex="-1"> <span id="settings-target-appearance-page-transitions" tabindex="-1">
@@ -1095,6 +1303,118 @@ watch(
</SettingsRow> </SettingsRow>
</SettingsSection> </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'"> <SettingsSection v-if="props.scope === 'content-downloads'">
<SettingsRow> <SettingsRow>
<template #label> <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>
@@ -12,6 +12,15 @@ import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const messages = defineMessages({ const messages = defineMessages({
forceUnicodeFont: {
id: 'app.settings.defaults.force-unicode-font',
defaultMessage: 'Force Unicode font',
},
forceUnicodeFontDescription: {
id: 'app.settings.defaults.force-unicode-font-description',
defaultMessage:
'Use the Unicode font when initializing a new instance. Off by default; existing font settings are preserved.',
},
fullscreen: { id: 'app.settings.defaults.fullscreen', defaultMessage: 'Fullscreen' }, fullscreen: { id: 'app.settings.defaults.fullscreen', defaultMessage: 'Fullscreen' },
fullscreenDescription: { fullscreenDescription: {
id: 'app.settings.defaults.fullscreen-description', id: 'app.settings.defaults.fullscreen-description',
@@ -205,6 +214,24 @@ watch(
</SettingsRow> </SettingsRow>
</SettingsSection> </SettingsSection>
<SettingsSection>
<SettingsRow>
<template #label>
<span id="settings-target-defaults-unicode-font" tabindex="-1">
{{ formatMessage(messages.forceUnicodeFont) }}
</span>
</template>
<template #description>{{ formatMessage(messages.forceUnicodeFontDescription) }}</template>
<template #control>
<Toggle
id="force-unicode-font"
v-model="settings.force_unicode_font"
:aria-label="formatMessage(messages.forceUnicodeFont)"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection> <SettingsSection>
<SettingsRow stacked> <SettingsRow stacked>
<template #label> <template #label>
Loaded 100 of 556 files, more files were not shown because too many files have changed in this diff. Show more