Compare commits

...
Author SHA1 Message Date
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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 18:52:50 +08:00
AxTps f1ff19e4b8 chore: 更新 sqlx 查询缓存(新增 ui_scale 列)
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 17:16:49 +08:00
AxTps 34cf186c33 feat: 界面缩放改用后端持久化 + 原生 webview zoom
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 16:21:30 +08:00
AxTps 81b7219085 feat: 恢复多人联机(陶瓦)侧边栏入口,挂于功能开关后
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 15:59:18 +08:00
AxTps 3cdc4951e0 chore: 发布 1.0.1-beta6 公告
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 11:46:59 +08:00
AxTps 02a8004fd5 fix: minimal 主页补充导览锚点
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 11:40:23 +08:00
AxTps 6b1d2e1943 fix: 修复新手导览自动跳过、卡住与步骤不跳转
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 11:27:27 +08:00
AxTps 6912e0aeb1 chore: 发布 1.0.1-beta5 公告
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (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
- 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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
taiki-e/install-action@v2.81.9 不支持 tombi 1.6.1(报 "version
1.6.1 is not supported")。改用 npx 安装 tombi@1.6.1,避免
install-action 的版本白名单限制,并与本地格式化版本一致。
2026-10-01 08:20:46 +08:00
AxTps 79fdddc075 chore: 修复仓库检查(tombi 格式、typos 拼写)
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (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
- 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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-09-29 17:36:10 +08:00
Disy efbf998ebb feat: GitHub 发布生成皮肤站完整更新包
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-09-28 15:27:14 +08:00
Disy ce0f41c973 fix: 修复实时日志与数据库切换可靠性
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-09-27 14:18:25 +08:00
AxTps 2efaa4fb35 chore: 发布 1.0.1-beta1 公告
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
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
- catalog.ts 增量新增 1.0.1-beta1 公告(首次启动欢迎弹窗);
- 网站 catalog.json 重新生成。
2026-09-27 08:39:49 +08:00
AxTps 2d52d16933 feat: 添加了首次启动器时弹出的公告
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-09-26 13:06:51 +08:00
Disy 2359b80250 fix: 修复自选目录实例的模组同步路径
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-09-26 11:00:01 +08:00
AxTps c273734aec fix: 临时回退内置自动更新以修复启动崩溃
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (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
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 / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
将仓库、Releases、Stars 及最新版下载等链接从自建 git.starlight.cool
改为 GitHub (github.com/ApTx2012/Starlight_Lancher)。
2026-09-26 08:19:01 +08:00
187 changed files with 12959 additions and 26898 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"
+23
View File
@@ -351,6 +351,29 @@ 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 != '' }} if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
env: env:
+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
+1
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
Generated
+2 -2
View File
@@ -6795,7 +6795,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "1.0.0-beta12" version = "1.0.0-beta15"
dependencies = [ dependencies = [
"ariadne", "ariadne",
"async-minecraft-ping", "async-minecraft-ping",
@@ -6899,7 +6899,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "1.0.0-beta12" version = "1.0.0-beta15"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"async_zip", "async_zip",
+6 -24
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,9 +30,7 @@ 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"
@@ -58,11 +54,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"
@@ -88,11 +80,7 @@ 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"
serde = "1.0.228" serde = "1.0.228"
@@ -127,10 +115,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"
@@ -149,10 +134,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 签名校验机制,自动在后台完成更新,无需手动下载安装更新。
| 系统平台 | 推荐下载文件 | | 系统平台 | 推荐下载文件 |
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.0-beta12", "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:*",
+97 -9
View File
@@ -8,6 +8,7 @@ import {
FlaskConicalIcon, FlaskConicalIcon,
FolderOpenIcon, FolderOpenIcon,
HomeIcon, HomeIcon,
ImagesIcon,
LeftArrowIcon, LeftArrowIcon,
LibraryIcon, LibraryIcon,
LogInIcon, LogInIcon,
@@ -19,6 +20,7 @@ import {
SettingsIcon, SettingsIcon,
SpinnerIcon, SpinnerIcon,
UserIcon, UserIcon,
UsersIcon,
WorldIcon, WorldIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
@@ -66,6 +68,8 @@ 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 AppActionBar from '@/components/ui/AppActionBar.vue' import AppActionBar from '@/components/ui/AppActionBar.vue'
@@ -79,7 +83,6 @@ import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/
import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue' import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue' import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue'
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue' import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue' import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue'
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue' import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
@@ -90,9 +93,9 @@ 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 SplashScreen from '@/components/ui/SplashScreen.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue' import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue' import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.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'
import { useDropImport } from '@/composables/useDropImport' import { useDropImport } from '@/composables/useDropImport'
@@ -138,9 +141,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 {
@@ -312,6 +315,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('')
@@ -490,9 +509,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
@@ -738,6 +764,10 @@ const messages = defineMessages({
defaultMessage: 'Library', defaultMessage: 'Library',
}, },
multiplayer: { multiplayer: {
screenshots: {
id: 'app.navigation.screenshots',
defaultMessage: 'Screenshots',
},
id: 'app.navigation.multiplayer', id: 'app.navigation.multiplayer',
defaultMessage: 'Multiplayer', defaultMessage: 'Multiplayer',
}, },
@@ -1066,6 +1096,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
@@ -1096,6 +1127,7 @@ async function setupApp() {
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)
@@ -1264,6 +1296,9 @@ provide(
) )
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview()) provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
provide('showLauncherPopup', (_request: unknown) => {}) provide('showLauncherPopup', (_request: unknown) => {})
provide('showStartupAnnouncement', () => startupAnnouncementModal.value?.show())
installUiScale()
const stateFailed = ref(false) const stateFailed = ref(false)
stateInitialization stateInitialization
@@ -1533,6 +1568,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,
@@ -1701,6 +1763,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][]
@@ -2161,6 +2224,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region /> <SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<SkinSiteSessionFrame v-if="stateInitialized" /> <SkinSiteSessionFrame v-if="stateInitialized" />
<InstancePlayerModal 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"
@@ -2256,6 +2320,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"
@@ -2423,9 +2504,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>
@@ -2465,7 +2550,10 @@ 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.userInformation) }} {{ formatMessage(messages.userInformation) }}
</h3> </h3>
@@ -2489,7 +2577,7 @@ 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" />
<NewModal <NewModal
ref="closeChoiceModal" ref="closeChoiceModal"
@@ -2577,7 +2665,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@install="handleContentInstallModpackInstall" @install="handleContentInstallModpackInstall"
@cancel="handleContentInstallModpackInstallCancel" @cancel="handleContentInstallModpackInstallCancel"
/> />
<TaggedModDownloadsModal /> <TaggedModDownloadsModal v-if="stateInitialized" />
<CurseForgeManualDownloadsModal <CurseForgeManualDownloadsModal
ref="contentInstallCurseForgeManualDownloadsModal" ref="contentInstallCurseForgeManualDownloadsModal"
@view-instance="handleContentInstallModpackDuplicateGoToInstance" @view-instance="handleContentInstallModpackDuplicateGoToInstance"
@@ -32,6 +32,208 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
] ]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.1-beta6',
version: '1.0.1-beta6',
publishedAt: '2026-10-01',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta6',
'zh-CN': 'Starlight Launcher 1.0.1-beta6',
},
changes: {
fixed: [
{
'en-US':
'Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.',
'zh-CN':
'修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
},
{
'en-US':
'Fixed the onboarding tour target on the minimal home layout.',
'zh-CN': '修复精简主页下导览目标找不到的问题。',
},
],
added: [
{
'en-US': 'Feature flag names in developer settings are now shown in Chinese.',
'zh-CN': '开发者设置中的功能开关名现在显示中文。',
},
],
},
},
{
id: 'launcher-1.0.1-beta5',
version: '1.0.1-beta5',
publishedAt: '2026-10-01',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta5',
'zh-CN': 'Starlight Launcher 1.0.1-beta5',
},
changes: {
added: [
{
'en-US':
'Reworked the onboarding tour, covering the home page, StarLight login, announcements, instance creation, discovery, skins, library, lab, downloads, and update settings in order.',
'zh-CN':
'重做了新手导览,依次介绍主页、StarLight 登录、公告、创建实例、发现内容、皮肤、实例库、实验室、下载与更新设置。',
},
],
fixed: [
{
'en-US':
'Fixed the developer feature flags page rendering blank.',
'zh-CN': '修复开发者“功能开关”页面空白的问题。',
},
{
'en-US':
'Fixed a build error that prevented the launcher from compiling.',
'zh-CN': '修复导致启动器无法编译的构建错误。',
},
],
},
},
{
id: 'launcher-1.0.1-beta4',
version: '1.0.1-beta4',
publishedAt: '2026-09-30',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta4',
'zh-CN': 'Starlight Launcher 1.0.1-beta4',
},
changes: {
fixed: [
{
'en-US':
'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
'zh-CN': '修复 1.0.1-beta3 中导致启动器无法编译的构建错误。',
},
],
},
},
{
id: 'launcher-1.0.1-beta3',
version: '1.0.1-beta3',
publishedAt: '2026-09-30',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta3',
'zh-CN': 'Starlight Launcher 1.0.1-beta3',
},
changes: {
added: [
{
'en-US':
'Added a launcher UI scale setting under Appearance, which also helps when embedded pages are cut off.',
'zh-CN': '在“界面与外观”中新增启动器缩放设置,同时可缓解内嵌网页被裁切的问题。',
},
{
'en-US':
'Added a one-time welcome announcement on first launch, with an entry in the home sidebar.',
'zh-CN': '新增首次启动时的欢迎公告弹窗,并在主页侧栏提供入口。',
},
],
fixed: [
{
'en-US':
'Fixed the browse page going blank without a CurseForge API key; the navigation tabs now stay visible.',
'zh-CN': '修复未配置 CurseForge API Key 时浏览页整页空白的问题,导航栏现在保持可见。',
},
{
'en-US':
'Fixed the about page license links and the update changelog link to point at the correct repository.',
'zh-CN': '修复关于页许可证链接与更新日志链接,改为指向正确的仓库。',
},
],
},
},
{
id: 'launcher-1.0.1-beta2',
version: '1.0.1-beta2',
publishedAt: '2026-09-28',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta2',
'zh-CN': 'Starlight Launcher 1.0.1-beta2',
},
changes: {
added: [
{
'en-US':
'GitHub releases now include one complete update package for publishing to the StarLight skin site.',
'zh-CN': 'GitHub 发布现会生成一个可直接发布到 StarLight 皮肤站的完整更新包。',
},
],
fixed: [
{
'en-US':
'Improved the reliability of live logs and database switching.',
'zh-CN': '提高实时日志与数据库切换的可靠性。',
},
],
},
},
{
id: 'launcher-1.0.1-beta1',
version: '1.0.1-beta1',
publishedAt: '2026-09-27',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta1',
'zh-CN': 'Starlight Launcher 1.0.1-beta1',
},
changes: {
added: [
{
'en-US':
'Added a one-time welcome announcement on first launch, covering the development team, the StarLight-only note, the rulebook update, and server connection addresses.',
'zh-CN':
'新增首次启动时的欢迎公告弹窗,包含开发团队、StarLight 专用说明、服规更新与服务器连接地址。',
},
],
},
},
{
id: 'launcher-1.0.0',
version: '1.0.0',
publishedAt: '2026-09-26',
title: {
'en-US': 'Starlight Launcher 1.0.0',
'zh-CN': 'Starlight Launcher 1.0.0',
},
changes: {
added: [
{
'en-US':
'Enabled the built-in automatic updater. The launcher can now check for and install new versions from the update channel.',
'zh-CN': '启用内置自动更新,启动器现在可以从更新通道检查并安装新版本。',
},
],
fixed: [
{
'en-US':
'Fixed a crash on startup caused by the updater running before launcher state finished initializing.',
'zh-CN': '修复了更新器在启动器状态初始化完成前运行导致的启动崩溃。',
},
],
},
},
{
id: 'launcher-1.0.0-beta15',
version: '1.0.0-beta15',
publishedAt: '2026-09-26',
title: {
'en-US': 'Starlight Launcher 1.0.0-beta15',
'zh-CN': 'Starlight Launcher 1.0.0-beta15',
},
changes: {
fixed: [
{
'en-US':
'Fixed the launcher crashing on startup in 1.0.0-beta14 by temporarily reverting the built-in updater. The updater will be re-enabled after the crash is resolved.',
'zh-CN':
'修复 1.0.0-beta14 中启动器一打开就崩溃的问题:暂时回退了内置自动更新。待崩溃问题解决后会重新启用。',
},
],
},
},
{ {
id: 'launcher-1.0.0-beta14', id: 'launcher-1.0.0-beta14',
version: '1.0.0-beta14', version: '1.0.0-beta14',
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { NewspaperIcon, RightArrowIcon } from '@modrinth/assets'
import { inject } from 'vue'
const showAnnouncement = inject<(() => void) | undefined>('showStartupAnnouncement')
function open() {
showAnnouncement?.()
}
</script>
<template>
<section
data-onboarding-id="home-announcement"
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
>
<button
type="button"
class="group flex w-full cursor-pointer flex-col gap-2 border-0 bg-transparent p-0 text-left"
@click="open"
>
<div class="flex items-center gap-2">
<NewspaperIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 truncate text-lg">公告</h2>
<RightArrowIcon
class="ml-auto size-4 shrink-0 text-secondary transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</div>
<p class="m-0 text-sm leading-relaxed text-secondary">点击查看最新公告</p>
</button>
</section>
</template>
@@ -28,8 +28,8 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { process_listener } from '@/helpers/events' import { 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 { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state' import { isInstanceLaunching } from '@/helpers/instance-launch-state'
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'
@@ -201,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">
@@ -36,6 +36,7 @@ const props = withDefaults(
limit?: HomeRecentLimit limit?: HomeRecentLimit
}>(), }>(),
{ {
dashboardSize: null,
limit: HOME_RECENT_DEFAULT_LIMIT, limit: HOME_RECENT_DEFAULT_LIMIT,
}, },
) )
@@ -146,11 +147,17 @@ async function stopInstance(instance: GameInstance) {
</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`"
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ProgressBar, defineMessages, useVIntl } from '@modrinth/ui' import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { computed } from 'vue' import { computed } from 'vue'
import { injectDownloadManager } from '@/providers/download-manager' import { injectDownloadManager } from '@/providers/download-manager'
const props = defineProps<{ instanceId?: string; active?: boolean }>() const props = defineProps<{ instanceId?: string; active?: boolean }>()
@@ -14,13 +14,14 @@ import {
import { users } from '@/helpers/auth' import { users } from '@/helpers/auth'
import { getInstanceMode } from '@/helpers/hosted-packs' import { getInstanceMode } from '@/helpers/hosted-packs'
import { import {
type InstancePlayer,
type PlayerChoice,
PlayerSelectionNavigatedAwayError, PlayerSelectionNavigatedAwayError,
registerInstancePlayerPicker, registerInstancePlayerPicker,
saveInstancePlayer, saveInstancePlayer,
waitForSkinSiteSession, waitForSkinSiteSession,
type InstancePlayer,
type PlayerChoice,
} from '@/helpers/instance-player' } from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages' import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled, useVIntl } from '@modrinth/ui' import { ButtonStyled, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue' import { onUnmounted, ref, watch } from 'vue'
import { import {
chooseInstancePlayer, chooseInstancePlayer,
getInstancePlayer, getInstancePlayer,
onInstancePlayerChanged,
type InstancePlayer, type InstancePlayer,
onInstancePlayerChanged,
} from '@/helpers/instance-player' } from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages' import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { NewModal, ProgressBar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui' import { ButtonStyled, defineMessages, NewModal, ProgressBar, useVIntl } from '@modrinth/ui'
import { onMounted, onUnmounted, nextTick, ref, shallowRef } from 'vue' import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { loading_listener } from '@/helpers/events' import { loading_listener } from '@/helpers/events'
import { progress_bars_list } from '@/helpers/state'
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs' import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
import { progress_bars_list } from '@/helpers/state'
import { import {
createTaggedModProgress, createTaggedModProgress,
type TaggedProgressEvent, type TaggedProgressEvent,
@@ -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)')
@@ -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'
@@ -20,8 +20,8 @@ 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 { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state' import { isInstanceLaunching } from '@/helpers/instance-launch-state'
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'
@@ -98,7 +98,7 @@ const checkProcess = async () => {
if (Array.isArray(runningProcesses)) 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()
if (loading.value) return if (loading.value) return
await run(props.instance.id).catch(async (err) => { await run(props.instance.id).catch(async (err) => {
@@ -111,7 +111,7 @@ const play = async (e, context) => {
await checkProcess() await checkProcess()
} }
const stop = async (e, context) => { const stop = async (e) => {
e?.stopPropagation() e?.stopPropagation()
await kill(props.instance.id).catch(handleError) await kill(props.instance.id).catch(handleError)
await checkProcess() await checkProcess()
@@ -205,7 +205,7 @@ onUnmounted(() => unlisten())
: formatMessage(isPlaying ? messages.launchAnother : 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 -->
@@ -295,7 +295,7 @@ onUnmounted(() => unlisten())
'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]" />
@@ -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>
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { clearHostedSession } from '@/helpers/hosted-packs'
import { import {
receiveSkinSiteMessage, receiveSkinSiteMessage,
@@ -10,6 +9,7 @@ import {
skinSiteStatus, skinSiteStatus,
skinSiteUser, skinSiteUser,
} from '@/composables/skin-site-session' } from '@/composables/skin-site-session'
import { clearHostedSession } from '@/helpers/hosted-packs'
const frame = ref<HTMLIFrameElement>() const frame = ref<HTMLIFrameElement>()
let lastMessage = 0 let lastMessage = 0
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ExternalIcon, UsersIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, NewModal } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { teamMembers } from '@/data/about'
const modal = ref<InstanceType<typeof NewModal>>()
const SERVER_IPS: { label: string; host: string }[] = [
{ label: '直连 IPv4', host: 'slv4.starlight.cool' },
{ label: '直连 IPv6', host: 'slv6.starlight.cool' },
{ label: '北方代理 IPv4', host: 'north.starlight.cool' },
{ label: '南方代理 IPv4', host: 'south.starlight.cool' },
{ label: '美国代理 IPv4', host: 'usa.starlight.cool' },
]
const RULES_URL = 'https://kdocs.cn/l/cmT6lVdryHJu'
const QQ_GROUP = '535392227'
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function openLink(url: string) {
void openUrl(url)
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" header="欢迎使用 Starlight Launcher" width="640px" max-width="640px">
<div class="flex min-w-0 flex-col gap-5">
<!-- 1. 开发组 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 flex items-center gap-2 text-sm font-semibold text-contrast">
<UsersIcon class="size-4 text-secondary" />
开发团队
</h3>
<p class="m-0 text-sm text-secondary">
本启动器由
<span class="font-semibold text-contrast">Ax_Tps</span>
和
<span class="font-semibold text-contrast">Disy920</span>
基于 Axolotl 开发。
</p>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="member in teamMembers"
:key="member.name"
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
@click.prevent="member.url && openLink(member.url)"
>
<Avatar :src="member.avatarUrl" :alt="member.name" size="2.5rem" circle no-shadow />
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{ member.name }}</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</div>
</section>
<!-- 2. SLS 专属 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">Starlight 专用启动器</h3>
<p class="m-0 text-sm text-secondary">
该启动器为 Starlight 专用。如果您还未加入 StarLight,请前往我们的 QQ 审核群
<button
type="button"
class="cursor-pointer border-0 bg-transparent p-0 font-semibold text-brand underline"
@click="openLink(`https://qm.qq.com/q/${QQ_GROUP}`)"
>
{{ QQ_GROUP }}
</button>
。
</p>
</section>
<!-- 3. 服规更新 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">StarLight 总则章程更新</h3>
<p class="m-0 text-sm text-secondary">
StarLight 总则章程(即“服规”)更新至第 15 版。该版服规进一步完善与严格了聚落建立、改组、招新与运营的全链条要求,请大家注意阅读并遵守!本版服规自
2026 年 9 月 27 日 0 点起开始试行。
</p>
<button
type="button"
class="flex w-fit cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-sm font-semibold text-brand underline"
@click="openLink(RULES_URL)"
>
查看服规链接
<ExternalIcon class="size-3.5" />
</button>
</section>
<!-- 4. 服务器连接 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">服务器连接</h3>
<p class="m-0 text-sm text-secondary">服务器目前下列几项 IP 可用:</p>
<ul class="m-0 flex flex-col gap-1 pl-5 text-sm text-secondary">
<li v-for="ip in SERVER_IPS" :key="ip.host">
<span class="text-contrast">{{ ip.label }}</span>
:<code class="text-brand">{{ ip.host }}</code>
</li>
</ul>
<p class="m-0 text-sm text-secondary">
请注意,我们优先推荐您尝试直连 IP(包括 v4 和 v6,哪个可用用哪个)。若没有出现高延迟或延迟波动严重的问题,尽量避免使用代理
IP(代理 IP 是流量计费的,使用会给服务器带来更大的经济压力)。
</p>
<p class="m-0 text-sm text-secondary">
若必须使用代理 IP,按以往经验,河北(含)以北的我国省份,在选用代理 IP 时建议优先选用北方代理,否则请使用南方代理。
</p>
</section>
</div>
<template #actions>
<div class="flex justify-end">
<ButtonStyled color="brand">
<button @click="hide">
<XIcon />
我知道了
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -61,7 +61,7 @@ const historyAnnouncements = computed(() =>
<UpdateAnnouncementContent <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>
@@ -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()
@@ -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;
@@ -276,6 +276,48 @@ export const onboardingMessages = defineMessages({
}, },
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 = (
@@ -331,20 +373,33 @@ const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescri
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = { export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
main: [ main: [
step( inspect(
'welcome', 'home-intro',
'manual', 'home-widget-grid',
copy( onboardingMessages.homeIntroTitle,
onboardingMessages.welcomeTitle, onboardingMessages.homeIntroDescription,
onboardingMessages.welcomeDescription,
onboardingMessages.start,
),
), ),
inspect( inspect(
'home-layout-switch', 'account-login',
'home-layout-switch', 'sidebar-accounts',
onboardingMessages.homeLayoutTitle, onboardingMessages.accountLoginTitle,
onboardingMessages.homeLayoutDescription, onboardingMessages.accountLoginDescription,
),
inspect(
'announcement',
'home-announcement',
onboardingMessages.announcementTitle,
onboardingMessages.announcementDescription,
),
step(
'create-starlight',
'navigate',
copy(
onboardingMessages.createStarlightTitle,
onboardingMessages.createStarlightDescription,
onboardingMessages.clickCreate,
),
control('create-instance', '/create'),
), ),
step( step(
'discover-navigation', 'discover-navigation',
@@ -354,7 +409,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',
@@ -362,22 +417,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',
@@ -394,6 +433,22 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.skinsPageTitle, onboardingMessages.skinsPageTitle,
onboardingMessages.skinsPageDescription, onboardingMessages.skinsPageDescription,
), ),
step(
'library-navigation',
'navigate',
copy(
onboardingMessages.libraryTitle,
onboardingMessages.libraryDescription,
onboardingMessages.clickLibrary,
),
control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
),
step( step(
'lab-navigation', 'lab-navigation',
'navigate', 'navigate',
@@ -428,7 +483,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,
@@ -436,95 +491,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'),
),
inspect(
'creation-instance-mode',
'creation-instance-mode',
onboardingMessages.instanceModeTitle,
onboardingMessages.instanceModeDescription,
),
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-starlight': { next: 'complete' },
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
'creation-method-import': { next: 'complete' },
},
},
),
inspect(
'creation-name',
'creation-name',
onboardingMessages.creationNameTitle,
onboardingMessages.creationNameDescription,
),
inspect(
'creation-loader',
'creation-loader',
onboardingMessages.creationLoaderTitle,
onboardingMessages.creationLoaderDescription,
),
step(
'creation-version',
'inspect',
copy(
onboardingMessages.creationVersionTitle,
onboardingMessages.creationVersionDescription,
onboardingMessages.continueArea,
),
{
targetId: 'creation-game-version',
nextByCreationPath: { custom: 'creation-confirm' },
},
),
step(
'creation-confirm',
'inspect',
copy(
onboardingMessages.creationConfirmTitle,
onboardingMessages.creationConfirmDescription,
onboardingMessages.finishArea,
),
{ targetId: 'creation-confirm' },
), ),
], ],
instance: [ instance: [
@@ -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
} }
@@ -284,7 +292,7 @@ export function useOnboardingTour(
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 +309,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,
}
}
@@ -26,6 +26,7 @@ import { computed, 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,
@@ -209,6 +210,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',
@@ -363,6 +373,12 @@ const customBackgroundPreview = computed(() =>
: null, : null,
) )
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
@@ -1020,6 +1036,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">
@@ -7,7 +7,7 @@ import {
Toggle, Toggle,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { inject, watch } from 'vue' import { inject, ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { isDev } from '@/helpers/utils' import { isDev } from '@/helpers/utils'
@@ -54,6 +54,25 @@ const messages = defineMessages({
}, },
}) })
const featureFlagLabels: Record<FeatureFlag, string> = {
project_background: '项目背景',
page_path: '页面路径',
worlds_tab: '世界标签页',
worlds_in_home: '主页显示世界',
server_project_qa: '服务器项目问答',
show_version_environment_column: '显示版本环境列',
server_ram_as_bytes_always_on: '服务器内存始终按字节显示',
always_show_app_controls: '始终显示窗口控件',
skip_non_essential_warnings: '跳过非必要警告',
skip_unknown_pack_warning: '跳过未知整合包警告',
i18n_debug: '国际化调试',
show_instance_play_time: '显示实例游玩时间',
page_transitions: '页面切换动画',
advanced_filters_collapsed: '高级筛选默认折叠',
multiplayer_in_app: '多人联机',
auto_install_dependencies: '自动安装依赖',
}
const settings = ref(await getSettings()) const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS)) const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
@@ -85,7 +104,7 @@ watch(
<template> <template>
<SettingsSection> <SettingsSection>
<SettingsRow v-for="option in options" :key="option"> <SettingsRow v-for="option in options" :key="option">
<template #label>{{ option.replaceAll('_', ' ') }}</template> <template #label>{{ featureFlagLabels[option] ?? option }}</template>
<template #control> <template #control>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
@@ -12,13 +12,13 @@ import {
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { inject, nextTick, ref, watch } from 'vue' import { nextTick, ref, watch } from 'vue'
import UpdateAnnouncementHistory from '@/components/ui/announcement/UpdateAnnouncementHistory.vue' import UpdateAnnouncementHistory from '@/components/ui/announcement/UpdateAnnouncementHistory.vue'
import { import {
betaDatabaseExists, betaDatabaseExists,
copyReleaseDatabaseToBeta,
copyDatabaseBetweenChannels, copyDatabaseBetweenChannels,
copyReleaseDatabaseToBeta,
getCurrentAppDatabasePath, getCurrentAppDatabasePath,
getUpdateChannel, getUpdateChannel,
getUpdatePreferences, getUpdatePreferences,
@@ -446,11 +446,14 @@ async function confirmDatabaseOperation() {
}), }),
}) })
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error)
handleError( handleError(
new Error( new Error(
`${
targetChannel === activeChannel targetChannel === activeChannel
? formatMessage(messages.databaseOperationActiveTarget) ? formatMessage(messages.databaseOperationActiveTarget)
: formatMessage(messages.databaseOperationFailed), : formatMessage(messages.databaseOperationFailed)
}: ${detail}`,
), ),
) )
} finally { } finally {
@@ -468,7 +471,7 @@ function cancelDatabaseOperation() {
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<SettingsSection :title="formatMessage(messages.title)"> <SettingsSection :title="formatMessage(messages.title)">
<div class="update-channel-panel"> <div class="update-channel-panel">
<p id="settings-target-updates-channel" class="m-0 text-sm leading-[1.45] text-secondary"> <p id="settings-target-updates-source" class="m-0 text-sm leading-[1.45] text-secondary">
{{ formatMessage(messages.description) }} {{ formatMessage(messages.description) }}
</p> </p>
<div <div
@@ -1,4 +1,4 @@
import { defineMessage, type MessageDescriptor } from '@modrinth/ui' import type { MessageDescriptor } from '@modrinth/ui'
export type SettingsCategoryId = export type SettingsCategoryId =
| 'interface' | 'interface'
@@ -28,100 +28,100 @@ export interface SettingsCategoryDefinition {
export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
{ {
id: 'interface', id: 'interface',
name: defineMessage({ name: {
id: 'app.settings.tabs.interface', id: 'app.settings.tabs.interface',
defaultMessage: 'Interface & appearance', defaultMessage: 'Interface & appearance',
}), },
group: 'launcher', group: 'launcher',
onboardingId: 'settings-tab-interface', onboardingId: 'settings-tab-interface',
}, },
{ {
id: 'home-navigation', id: 'home-navigation',
name: defineMessage({ name: {
id: 'app.settings.tabs.home-navigation', id: 'app.settings.tabs.home-navigation',
defaultMessage: 'Home & navigation', defaultMessage: 'Home & navigation',
}), },
group: 'launcher', group: 'launcher',
onboardingId: 'settings-tab-home-navigation', onboardingId: 'settings-tab-home-navigation',
}, },
{ {
id: 'language-translation', id: 'language-translation',
name: defineMessage({ name: {
id: 'app.settings.tabs.language-translation', id: 'app.settings.tabs.language-translation',
defaultMessage: 'Language & translation', defaultMessage: 'Language & translation',
}), },
group: 'launcher', group: 'launcher',
onboardingId: 'settings-tab-language-translation', onboardingId: 'settings-tab-language-translation',
}, },
{ {
id: 'ai', id: 'ai',
name: defineMessage({ id: 'app.settings.tabs.ai', defaultMessage: 'AI' }), name: { id: 'app.settings.tabs.ai', defaultMessage: 'AI' },
group: 'launcher', group: 'launcher',
flushContent: true, flushContent: true,
onboardingId: 'settings-tab-ai', onboardingId: 'settings-tab-ai',
}, },
{ {
id: 'java-performance', id: 'java-performance',
name: defineMessage({ name: {
id: 'app.settings.tabs.java-performance', id: 'app.settings.tabs.java-performance',
defaultMessage: 'Java & performance', defaultMessage: 'Java & performance',
}), },
group: 'game', group: 'game',
onboardingId: 'settings-tab-java-performance', onboardingId: 'settings-tab-java-performance',
}, },
{ {
id: 'launch-defaults', id: 'launch-defaults',
name: defineMessage({ name: {
id: 'app.settings.tabs.launch-defaults', id: 'app.settings.tabs.launch-defaults',
defaultMessage: 'Launch & instance defaults', defaultMessage: 'Launch & instance defaults',
}), },
group: 'game', group: 'game',
onboardingId: 'settings-tab-launch-defaults', onboardingId: 'settings-tab-launch-defaults',
}, },
{ {
id: 'content-downloads', id: 'content-downloads',
name: defineMessage({ name: {
id: 'app.settings.tabs.content-downloads', id: 'app.settings.tabs.content-downloads',
defaultMessage: 'Content & downloads', defaultMessage: 'Content & downloads',
}), },
group: 'game', group: 'game',
onboardingId: 'settings-tab-content-downloads', onboardingId: 'settings-tab-content-downloads',
}, },
{ {
id: 'network-multiplayer', id: 'network-multiplayer',
name: defineMessage({ name: {
id: 'app.settings.tabs.network-multiplayer', id: 'app.settings.tabs.network-multiplayer',
defaultMessage: 'Network & multiplayer', defaultMessage: 'Network & multiplayer',
}), },
group: 'game', group: 'game',
onboardingId: 'settings-tab-network-multiplayer', onboardingId: 'settings-tab-network-multiplayer',
}, },
{ {
id: 'storage-backups', id: 'storage-backups',
name: defineMessage({ name: {
id: 'app.settings.tabs.storage-backups', id: 'app.settings.tabs.storage-backups',
defaultMessage: 'Storage & backups', defaultMessage: 'Storage & backups',
}), },
group: 'data-privacy', group: 'data-privacy',
onboardingId: 'settings-tab-storage-backups', onboardingId: 'settings-tab-storage-backups',
}, },
{ {
id: 'updates', id: 'updates',
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }), name: { id: 'app.settings.tabs.updates', defaultMessage: 'Updates' },
group: 'support', group: 'support',
onboardingId: 'settings-tab-updates', onboardingId: 'settings-tab-updates',
}, },
{ {
id: 'about', id: 'about',
name: defineMessage({ id: 'app.settings.tabs.about', defaultMessage: 'About' }), name: { id: 'app.settings.tabs.about', defaultMessage: 'About' },
group: 'support', group: 'support',
}, },
{ {
id: 'feature-flags', id: 'feature-flags',
name: defineMessage({ name: {
id: 'settings.feature-flags.title', id: 'settings.feature-flags.title',
defaultMessage: 'Feature flags', defaultMessage: 'Feature flags',
}), },
group: 'developer', group: 'developer',
developerOnly: true, developerOnly: true,
}, },
@@ -15,7 +15,8 @@ import {
UsersIcon, UsersIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { commonMessages, defineMessages, type MessageDescriptor } from '@modrinth/ui' import { commonMessages, defineMessages, type MessageDescriptor } from '@modrinth/ui'
import { defineAsyncComponent, type Component } from 'vue' import { type Component, defineAsyncComponent } from 'vue'
import { import {
getVisibleSettingsCategoryDefinitions, getVisibleSettingsCategoryDefinitions,
type SettingsCategoryDefinition, type SettingsCategoryDefinition,
@@ -29,8 +29,8 @@ import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { get_project } from '@/helpers/cache' import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state' import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils' import { showInstanceInFolder } from '@/helpers/utils'
import { handleSevereError } from '@/store/error' import { handleSevereError } from '@/store/error'
@@ -1,120 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createLatestLogReader } from './latest-log-reader.ts'
test('follows file-only output after startup, re-entry, clear and file rotation', async () => {
let file = ''
let displayed = 'stdout startup'
const reader = createLatestLogReader(
async (cursor) => ({
cursor: file.length,
output: file.slice(cursor > file.length ? 0 : cursor),
new_file: cursor > file.length,
}),
(output, replace) => {
displayed = (replace ? '' : displayed) + output
},
)
await reader.refresh()
assert.equal(reader.active, false)
file = 'Startup complete\n'
await reader.refresh()
assert.equal(reader.active, true)
assert.equal(displayed, file) // Replaces stdout, not appended a second time.
file += 'Joined world\nDisconnected\n'
await reader.refresh()
await reader.refresh() // Re-entering the view must not replay history.
assert.equal(displayed, file)
reader.clear()
displayed = ''
file += 'After clear\n'
await reader.refresh()
assert.equal(displayed, 'After clear\n')
file = 'Rotated\n'
await reader.refresh()
assert.equal(displayed, file)
file = ''
await reader.refresh()
assert.equal(displayed, '')
assert.equal(reader.active, false)
file = 'File recreated\n'
await reader.refresh()
assert.equal(displayed, file)
reader.reset()
assert.equal(reader.active, false)
file = 'New session with a longer file\n'
await reader.refresh()
assert.equal(displayed, file)
})
test('serializes reads, ignores pre-restart responses, consumes pre-clear responses', async () => {
const resolvers: ((value: { cursor: number; output: string; new_file: boolean }) => void)[] = []
const cursors: number[] = []
const output: string[] = []
const reader = createLatestLogReader(
(cursor) => {
cursors.push(cursor)
return new Promise((resolve) => resolvers.push(resolve))
},
(text) => output.push(text),
)
const old = reader.refresh()
const concurrent = reader.refresh()
assert.deepEqual(cursors, [0])
reader.reset()
const fresh = reader.refresh()
resolvers[0]({ cursor: 100, output: 'old session', new_file: false })
await Promise.all([old, concurrent])
assert.deepEqual(output, [])
reader.clear()
resolvers[1]({ cursor: 20, output: 'before clear', new_file: false })
await fresh
assert.deepEqual(output, [])
const next = reader.refresh()
assert.deepEqual(cursors, [0, 0, 20])
resolvers[2]({ cursor: 40, output: 'after clear\n', new_file: false })
await next
assert.deepEqual(output, ['after clear\n'])
})
test('a temporary read failure keeps the cursor and retries without duplicating output', async () => {
let failed = true
const output: string[] = []
const reader = createLatestLogReader(
async (cursor) => {
assert.equal(cursor, 0)
if (failed) throw new Error('file temporarily unavailable')
return { cursor: 6, output: 'ready\n', new_file: false }
},
(text) => output.push(text),
)
await assert.rejects(reader.refresh())
assert.equal(reader.active, false)
failed = false
await reader.refresh()
assert.deepEqual(output, ['ready\n'])
})
test('joins split lines and flushes the final unterminated line on exit', async () => {
let chunk = 'Startup\n[Render thread/INFO] Join'
let cursor = 0
let displayed = ''
const reader = createLatestLogReader(
async () => ({
cursor: (cursor += chunk.length),
output: chunk,
new_file: false,
}),
(text, replace) => {
displayed = (replace ? '' : displayed) + text
},
)
await reader.refresh()
assert.equal(displayed, 'Startup\n')
chunk = 'ed world\nLast line'
await reader.refresh()
assert.equal(displayed, 'Startup\n[Render thread/INFO] Joined world\n')
reader.flush()
assert.equal(displayed, 'Startup\n[Render thread/INFO] Joined world\nLast line')
})
@@ -1,88 +0,0 @@
interface LogCursor {
cursor: number
output: string
new_file: boolean
}
/** One authoritative stream: use process events until latest.log becomes available. */
export function createLatestLogReader(
read: (cursor: number) => Promise<LogCursor>,
append: (output: string, replace: boolean) => void,
) {
let cursor = 0
let active = false
let session = 0
let cleared = 0
let partialLine = ''
let replaceOnAppend = false
let pending: Promise<void> | null = null
function refresh(): Promise<void> {
if (pending) return pending
const requestSession = session
const requestClear = cleared
const request = (async () => {
const result = await read(cursor)
if (requestSession !== session) return
const rotated = result.new_file || result.cursor < cursor
const replace = !active || rotated
cursor = result.cursor
if (result.cursor === 0 && !result.output) {
if (rotated) {
active = false
partialLine = ''
replaceOnAppend = false
if (requestClear === cleared) append('', true)
}
return
}
active = true
// Clearing during a read consumes its bytes without restoring cleared text.
if (requestClear === cleared && (result.output || result.new_file)) {
if (replace) {
partialLine = ''
replaceOnAppend = true
}
partialLine += result.output
// File writes can end mid-line. Do not turn the next chunk into a
// separate log entry; bound unterminated output just like the console.
const end =
partialLine.length > 64 * 1024 ? partialLine.length : partialLine.lastIndexOf('\n') + 1
if (end > 0) {
append(partialLine.slice(0, end), replaceOnAppend)
partialLine = partialLine.slice(end)
replaceOnAppend = false
}
}
})()
pending = request
return request.finally(() => {
if (pending === request) pending = null
})
}
return {
refresh,
get active() {
return active
},
clear() {
cleared++
partialLine = ''
replaceOnAppend = false
},
flush() {
if (partialLine) append(partialLine, replaceOnAppend)
partialLine = ''
replaceOnAppend = false
},
reset() {
session++
cursor = 0
active = false
partialLine = ''
replaceOnAppend = false
pending = null
},
}
}
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createLiveLogReconciler,
type LiveLogSnapshot,
} from './live-log-reconciler.ts'
test('merges the authoritative snapshot with queued events without gaps or duplicates', async () => {
let resolveSnapshot!: (snapshot: LiveLogSnapshot) => void
const displayed: number[] = []
const reconciler = createLiveLogReconciler(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve
}),
(snapshot) => {
displayed.splice(
0,
displayed.length,
...snapshot.lines.map((line) => line.sequence),
)
},
(event) => displayed.push(event.last_sequence),
)
const syncing = reconciler.sync()
reconciler.accept({ first_sequence: 3, last_sequence: 3 })
resolveSnapshot({
lines: [
{ sequence: 1, message: 'one' },
{ sequence: 2, message: 'two' },
],
last_sequence: 2,
})
await syncing
assert.deepEqual(displayed, [1, 2, 3])
await reconciler.accept({ first_sequence: 3, last_sequence: 3 })
assert.deepEqual(displayed, [1, 2, 3])
})
test('resynchronizes when an event sequence reveals a missed batch', async () => {
const displayed: number[] = []
let loads = 0
const reconciler = createLiveLogReconciler(
async () => {
loads++
return {
lines: [1, 2, 3, 4].map((sequence) => ({
sequence,
message: String(sequence),
})),
last_sequence: 4,
}
},
(snapshot) => {
displayed.splice(
0,
displayed.length,
...snapshot.lines.map((line) => line.sequence),
)
},
(event) => displayed.push(event.last_sequence),
)
await reconciler.accept({ first_sequence: 4, last_sequence: 4 })
assert.equal(loads, 1)
assert.deepEqual(displayed, [1, 2, 3, 4])
})
@@ -0,0 +1,81 @@
export interface LiveLogSnapshot {
lines: Array<{ sequence: number; message: string }>
last_sequence: number
}
export interface SequencedLogEvent {
first_sequence: number
last_sequence: number
}
export function createLiveLogReconciler<T extends SequencedLogEvent>(
loadSnapshot: () => Promise<LiveLogSnapshot>,
replace: (snapshot: LiveLogSnapshot) => void,
append: (event: T) => void,
) {
let lastSequence = 0
let queued: T[] = []
let syncing: Promise<void> | null = null
let disposed = false
function drainQueued(): boolean {
const events = queued
queued = []
for (let index = 0; index < events.length; index++) {
const event = events[index]!
if (event.last_sequence <= lastSequence) continue
if (event.first_sequence > lastSequence + 1) {
queued.push(...events.slice(index))
return false
}
append(event)
lastSequence = event.last_sequence
}
return true
}
function sync(): Promise<void> {
if (syncing) return syncing
const request = (async () => {
do {
const snapshot = await loadSnapshot()
if (disposed) return
replace(snapshot)
lastSequence = snapshot.last_sequence
} while (!drainQueued())
})()
const completion = request.finally(() => {
if (syncing === completion) syncing = null
})
syncing = completion
return completion
}
function resync(): Promise<void> {
return syncing ? syncing.then(sync) : sync()
}
function accept(event: T): Promise<void> | undefined {
if (disposed || event.last_sequence <= lastSequence) return
if (syncing) {
queued.push(event)
return syncing
}
if (event.first_sequence > lastSequence + 1) {
queued.push(event)
return sync()
}
append(event)
lastSequence = event.last_sequence
}
return {
accept,
resync,
sync,
dispose() {
disposed = true
queued = []
},
}
}
@@ -1,21 +1,20 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { useHostedSync } from './useHostedSync.ts'
import { useHostedCreation, forgetHostedCreation } from './useHostedCreation.ts'
import { import {
clearHostedSession, clearHostedSession,
HOSTED_ACCOUNT_REQUIRED_MESSAGE,
hostedCreate, hostedCreate,
hostedDefault, hostedDefault,
hostedSync, hostedSync,
onHostedPackAttemptStarted, onHostedPackAttemptStarted,
setInstanceMode, setInstanceMode,
} from '../helpers/hosted-packs.ts' } from '../helpers/hosted-packs.ts'
import { import {
openSkinSiteLogin, openSkinSiteLogin,
receiveSkinSiteMessage, receiveSkinSiteMessage,
requestSkinSiteLuck,
requestSkinSiteDownloadToken, requestSkinSiteDownloadToken,
requestSkinSiteLuck,
requestSkinSitePlayers, requestSkinSitePlayers,
requestSkinSiteSkinUpdate, requestSkinSiteSkinUpdate,
resetSkinSiteSession, resetSkinSiteSession,
@@ -27,6 +26,8 @@ import {
skinSiteStatus, skinSiteStatus,
skinSiteUser, skinSiteUser,
} from './skin-site-session.ts' } from './skin-site-session.ts'
import { forgetHostedCreation, useHostedCreation } from './useHostedCreation.ts'
import { useHostedSync } from './useHostedSync.ts'
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => { test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
const frame = { postMessage() {} } as unknown as Window const frame = { postMessage() {} } as unknown as Window
@@ -46,6 +47,10 @@ test('hosted installation sends the JWT to native commands while Local needs no
let createdId = 'instance' let createdId = 'instance'
let instanceExists = false let instanceExists = false
let tokenRequests = 0 let tokenRequests = 0
let accounts: Array<{
account_type: string
yggdrasil?: { api_root: string }
}> = [{ account_type: 'microsoft' }]
const frame = { const frame = {
postMessage(data: { type: string; requestId: string }) { postMessage(data: { type: string; requestId: string }) {
if (data.type !== 'starlight-pack-token-request') return if (data.type !== 'starlight-pack-token-request') return
@@ -70,6 +75,14 @@ test('hosted installation sends the JWT to native commands while Local needs no
__TAURI_INTERNALS__: { __TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) { async invoke(command: string, args: Record<string, unknown>) {
calls.push({ command, args }) calls.push({ command, args })
if (command === 'plugin:install|hosted_has_game_account')
return accounts.some(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
'https://skin.starlight.cool/yggdrasil'),
)
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse() if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
if (command === 'plugin:instance|instance_get') if (command === 'plugin:instance|instance_get')
return instanceExists ? { id: args.instanceId } : null return instanceExists ? { id: args.instanceId } : null
@@ -99,7 +112,13 @@ test('hosted installation sends the JWT to native commands while Local needs no
await setInstanceMode('instance', 'starlight') await setInstanceMode('instance', 'starlight')
assert.equal(tokenRequests, 4) assert.equal(tokenRequests, 4)
assert.deepEqual( assert.deepEqual(
calls.map(({ command }) => command), calls
.map(({ command }) => command)
.filter(
(command) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
),
[ [
'hosted_set_session', 'hosted_set_session',
'hosted_default', 'hosted_default',
@@ -210,9 +229,35 @@ test('hosted installation sends the JWT to native commands while Local needs no
assert.equal(tokenRequests, requestsBeforeLogout) assert.equal(tokenRequests, requestsBeforeLogout)
const attempts: string[] = [] const attempts: string[] = []
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id)) const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
accounts = []
const installCallsBeforeAccountFailure = calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length
await assert.rejects(hostedSync('failed-instance'), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
await assert.rejects(hostedCreate(), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
assert.deepEqual(attempts, [])
assert.equal(
calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length,
installCallsBeforeAccountFailure,
)
const unauthenticatedCreation = useHostedCreation()
await unauthenticatedCreation.install()
assert.equal(unauthenticatedCreation.installError.value, HOSTED_ACCOUNT_REQUIRED_MESSAGE)
assert.equal(unauthenticatedCreation.createdInstance.value, undefined)
accounts = [{ account_type: 'microsoft' }]
const unauthenticatedRetry = hostedSync('failed-instance') const unauthenticatedRetry = hostedSync('failed-instance')
assert.deepEqual(attempts, ['failed-instance'])
await assert.rejects(unauthenticatedRetry) await assert.rejects(unauthenticatedRetry)
assert.deepEqual(attempts, ['failed-instance'])
stopListening() stopListening()
await assert.rejects(hostedSync('failed-instance')) await assert.rejects(hostedSync('failed-instance'))
assert.equal(attempts.length, 1) assert.equal(attempts.length, 1)
@@ -0,0 +1,17 @@
import { onUnmounted } from 'vue'
import { instance_listener } from '@/helpers/events.js'
export interface InstanceAppEvent {
event: string
instance_id?: string
}
export function useAppEvent(type: 'instance', callback: (event: InstanceAppEvent) => void) {
if (type !== 'instance') return
let unlisten: (() => void) | undefined
void instance_listener(callback).then((dispose) => {
unlisten = dispose
})
onUnmounted(() => unlisten?.())
}
@@ -0,0 +1,154 @@
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
export type ImageThumbnailSource = {
instanceId: string
filePath: string
path: string
}
type ThumbnailCacheEntry = {
promise: Promise<string>
url?: string
users: number
lastUsed: number
}
const MAX_CONCURRENT_THUMBNAILS = 4
const MAX_CACHED_THUMBNAILS = 256
const thumbnailCache = new Map<string, ThumbnailCacheEntry>()
const thumbnailQueue: Array<() => void> = []
let activeThumbnailRequests = 0
function runThumbnailQueue() {
while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) {
activeThumbnailRequests += 1
thumbnailQueue.shift()?.()
}
}
function enqueueThumbnail<T>(task: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
thumbnailQueue.push(() => {
void task()
.then(resolve, reject)
.finally(() => {
activeThumbnailRequests -= 1
runThumbnailQueue()
})
})
runThumbnailQueue()
})
}
function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) {
if (thumbnailCache.get(cacheKey) !== entry) return
thumbnailCache.delete(cacheKey)
if (entry.url) {
URL.revokeObjectURL(entry.url)
} else {
void entry.promise.then(
(url) => URL.revokeObjectURL(url),
() => undefined,
)
}
}
function trimThumbnailCache() {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return
const unused = [...thumbnailCache.entries()]
.filter(([, entry]) => entry.users === 0)
.sort(([, left], [, right]) => left.lastUsed - right.lastUsed)
for (const [cacheKey, entry] of unused) {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break
removeThumbnail(cacheKey, entry)
}
}
function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) {
const cacheKey = JSON.stringify([
source.instanceId,
source.filePath,
maxDimension,
revision ?? null,
])
let entry = thumbnailCache.get(cacheKey)
if (!entry) {
entry = {
users: 0,
lastUsed: performance.now(),
promise: enqueueThumbnail(async () => {
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
instanceId: source.instanceId,
filePath: source.filePath,
maxDimension,
})
return URL.createObjectURL(new Blob([bytes]))
}),
}
thumbnailCache.set(cacheKey, entry)
void entry.promise.then(
(url) => {
entry!.url = url
trimThumbnailCache()
},
() => {
if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey)
},
)
}
entry.users += 1
entry.lastUsed = performance.now()
return {
promise: entry.promise,
release: () => {
entry!.users = Math.max(0, entry!.users - 1)
entry!.lastUsed = performance.now()
trimThumbnailCache()
},
}
}
export function useImageThumbnail(
source: MaybeRefOrGetter<ImageThumbnailSource | null | undefined>,
maxDimension: MaybeRefOrGetter<number> = 512,
revision: MaybeRefOrGetter<string | number | undefined> = undefined,
) {
const thumbnail = ref('')
watch(
[() => toValue(source), () => toValue(maxDimension), () => toValue(revision)],
([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => {
thumbnail.value = ''
if (!currentSource) return
let active = true
const fallbackUrl = convertFileSrc(currentSource.path)
const acquired = acquireThumbnail(
currentSource,
Math.max(1, Math.round(currentMaxDimension)),
currentRevision,
)
onCleanup(() => {
active = false
acquired.release()
})
void acquired.promise.then(
(url) => {
if (active) thumbnail.value = url
},
(error) => {
console.warn('Could not create image thumbnail', error)
if (active) thumbnail.value = fallbackUrl
},
)
},
{ immediate: true },
)
return thumbnail
}
@@ -13,9 +13,9 @@ import type {
SymlinkMethodChoice, SymlinkMethodChoice,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useDebugLogger, useGlobalDrop, useInstanceContext, useVIntl } from '@modrinth/ui' import { useDebugLogger, useGlobalDrop, useInstanceContext, useVIntl } from '@modrinth/ui'
import { join } from '@tauri-apps/api/path'
import { computed, type ComputedRef, nextTick, ref } from 'vue' import { computed, type ComputedRef, nextTick, ref } from 'vue'
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import { join } from '@tauri-apps/api/path'
import { import {
classifyDroppedItem, classifyDroppedItem,
@@ -1412,10 +1412,7 @@ export function useDropImport(options: DropImportOptions) {
cleanupLauncherZipTemp() cleanupLauncherZipTemp()
} }
function resolvedInstancePath( function resolvedInstancePath(inst: SelectedInstance): string | undefined {
inst: SelectedInstance,
ctx: ImportContext | null,
): string | undefined {
if (inst.compatibleMode) return inst.versionPath if (inst.compatibleMode) return inst.versionPath
return inst.path return inst.path
} }
@@ -10,6 +10,10 @@ const createdInstance = ref<string>()
const completed = ref(false) const completed = ref(false)
let generation = 0 let generation = 0
function errorMessage(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause)
}
export function forgetHostedCreation(instanceId: string) { export function forgetHostedCreation(instanceId: string) {
if (createdInstance.value !== instanceId) return if (createdInstance.value !== instanceId) return
generation++ generation++
@@ -27,7 +31,7 @@ export function markHostedCreationCompleted(instanceId: string) {
export function markHostedCreationFailed(instanceId: string, cause: unknown) { export function markHostedCreationFailed(instanceId: string, cause: unknown) {
if (createdInstance.value !== instanceId) return if (createdInstance.value !== instanceId) return
completed.value = false completed.value = false
installError.value = String(cause) installError.value = errorMessage(cause)
} }
export function useHostedCreation() { export function useHostedCreation() {
@@ -63,7 +67,7 @@ export function useHostedCreation() {
} catch (cause) { } catch (cause) {
if (attempt === generation) { if (attempt === generation) {
if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause) if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause)
else installError.value = String(cause) else installError.value = errorMessage(cause)
} }
} finally { } finally {
installing.value = false installing.value = false
@@ -2,12 +2,14 @@ import { createConsoleState } from '@modrinth/ui'
import { import {
clear_log_buffer, clear_log_buffer,
get_live_log_buffer, get_live_log_snapshot,
get_logs, get_logs,
get_minecraft_latest_log_cursor,
} from '@/helpers/logs' } from '@/helpers/logs'
import { createLatestLogReader } from './latest-log-reader' import {
createLiveLogReconciler,
type SequencedLogEvent,
} from './live-log-reconciler'
type ConsoleState = ReturnType<typeof createConsoleState> type ConsoleState = ReturnType<typeof createConsoleState>
@@ -25,8 +27,17 @@ interface InstanceConsoleEntry {
historicalConsole: ConsoleState historicalConsole: ConsoleState
historicalCache: Map<string, string> historicalCache: Map<string, string>
logList: LogEntry[] | null logList: LogEntry[] | null
liveHistoryHydration: Promise<void> | null liveLog: ReturnType<typeof createLiveLogReconciler<ProcessLogPayload>>
latestLog: ReturnType<typeof createLatestLogReader> }
interface ProcessLogPayload extends SequencedLogEvent {
type: 'log4j' | 'legacy'
message?: string
logger_name?: string
level?: string
thread_name?: string
timestamp_millis?: number
throwable?: string
} }
const instances = new Map<string, InstanceConsoleEntry>() const instances = new Map<string, InstanceConsoleEntry>()
@@ -36,19 +47,28 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
if (entry) return entry if (entry) return entry
const liveConsole = createConsoleState() const liveConsole = createConsoleState()
const liveLog = createLiveLogReconciler<ProcessLogPayload>(
() => get_live_log_snapshot(instanceId),
(snapshot) => {
liveConsole.clear()
void liveConsole.addLegacyLog(
snapshot.lines.map((line) => line.message).join('\n'),
)
},
(payload) => {
if (payload.type === 'log4j') {
liveConsole.addLog4jEvent(payload)
} else if (payload.message !== undefined) {
void liveConsole.addLegacyLog(payload.message)
}
},
)
entry = { entry = {
liveConsole, liveConsole,
historicalConsole: createConsoleState(), historicalConsole: createConsoleState(),
historicalCache: new Map(), historicalCache: new Map(),
logList: null, logList: null,
liveHistoryHydration: null, liveLog,
latestLog: createLatestLogReader(
(cursor) => get_minecraft_latest_log_cursor(instanceId, cursor),
(output, replace) => {
if (replace) liveConsole.clear()
void liveConsole.addLegacyLog(output)
},
),
} }
instances.set(instanceId, entry) instances.set(instanceId, entry)
return entry return entry
@@ -56,28 +76,7 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
async function hydrate(instanceId: string): Promise<void> { async function hydrate(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId) const entry = getOrCreate(instanceId)
if (entry.liveHistoryHydration) { await entry.liveLog.sync()
return entry.liveHistoryHydration
}
const hydration = (async () => {
await entry.latestLog.refresh().catch(console.warn)
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
const buffer = await get_live_log_buffer(instanceId)
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
await entry.liveConsole.addLegacyLog(buffer)
}
}
})()
entry.liveHistoryHydration = hydration
try {
await hydration
} finally {
if (entry.liveHistoryHydration === hydration) {
entry.liveHistoryHydration = null
}
}
} }
async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> { async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> {
@@ -109,15 +108,13 @@ function invalidate(instanceId: string): void {
async function clearLive(instanceId: string): Promise<void> { async function clearLive(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId) const entry = getOrCreate(instanceId)
entry.latestLog.clear() await clear_log_buffer(instanceId)
entry.liveConsole.clear() await entry.liveLog.resync()
await clear_log_buffer(instanceId).catch(() => {})
} }
async function destroy(instanceId: string): Promise<void> { function destroy(instanceId: string): void {
instances.get(instanceId)?.latestLog.reset() instances.get(instanceId)?.liveLog.dispose()
instances.delete(instanceId) instances.delete(instanceId)
await clear_log_buffer(instanceId).catch(() => {})
} }
export function useInstanceConsole(instanceId: string) { export function useInstanceConsole(instanceId: string) {
@@ -130,13 +127,8 @@ export function useInstanceConsole(instanceId: string) {
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename), getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
invalidate: () => invalidate(instanceId), invalidate: () => invalidate(instanceId),
clearLive: () => clearLive(instanceId), clearLive: () => clearLive(instanceId),
refreshLive: () => entry.latestLog.refresh(), appendLive: (payload: ProcessLogPayload) => entry.liveLog.accept(payload),
flushLive: () => entry.latestLog.flush(), resyncLive: () => entry.liveLog.resync(),
acceptsProcessLogs: () => !entry.latestLog.active,
resetLive: () => {
entry.latestLog.reset()
entry.liveConsole.clear()
},
destroy: () => destroy(instanceId), destroy: () => destroy(instanceId),
} }
} }
+2 -1
View File
@@ -4,7 +4,8 @@ export const AxolotlBrandConfig = Object.freeze({
productName: 'Starlight Launcher', productName: 'Starlight Launcher',
shortProductName: 'Starlight', shortProductName: 'Starlight',
website: 'https://skin.starlight.cool/', website: 'https://skin.starlight.cool/',
repositoryUrl: 'https://git.starlight.cool/Disy/StarLight-Yggdrasil-Server', repositoryUrl: 'https://github.com/ApTx2012/Starlight_Lancher',
releasesUrl: 'https://github.com/ApTx2012/Starlight_Lancher/releases',
supportUrl: 'https://skin.starlight.cool/', supportUrl: 'https://skin.starlight.cool/',
qqGroupNumber: '000000000', qqGroupNumber: '000000000',
qqChannelUrl: 'https://skin.starlight.cool/', qqChannelUrl: 'https://skin.starlight.cool/',
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { createHostedDownloadFailures } from './hosted-download-failures.ts' import { createHostedDownloadFailures } from './hosted-download-failures.ts'
test('retry clears only its own failure before progress arrives and ignores late old events', () => { test('retry clears only its own failure before progress arrives and ignores late old events', () => {
+16 -3
View File
@@ -1,4 +1,5 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { import {
requestSkinSiteDownloadToken, requestSkinSiteDownloadToken,
skinSiteStatus, skinSiteStatus,
@@ -6,6 +7,8 @@ import {
waitForSkinSiteSession, waitForSkinSiteSession,
} from '../composables/skin-site-session.ts' } from '../composables/skin-site-session.ts'
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
let sessionUpdate: Promise<void> = Promise.resolve() let sessionUpdate: Promise<void> = Promise.resolve()
const attemptListeners = new Set<(instanceId: string) => void>() const attemptListeners = new Set<(instanceId: string) => void>()
@@ -20,6 +23,13 @@ function startHostedPackAttempt(instanceId: string) {
for (const listener of attemptListeners) listener(instanceId) for (const listener of attemptListeners) listener(instanceId)
} }
export async function requireHostedGameAccount(): Promise<void> {
const available = await invokeHosted<boolean>('plugin:install|hosted_has_game_account')
if (!available) {
throw new Error(HOSTED_ACCOUNT_REQUIRED_MESSAGE)
}
}
export function clearHostedSession(): Promise<void> { export function clearHostedSession(): Promise<void> {
const update = sessionUpdate const update = sessionUpdate
.catch(() => {}) .catch(() => {})
@@ -99,13 +109,16 @@ export interface HostedSyncResult {
} }
export const hostedDefault = () => export const hostedDefault = () =>
invokeWithSession<HostedPublication>('plugin:install|hosted_default') invokeWithSession<HostedPublication>('plugin:install|hosted_default')
export const hostedCreate = (gameDirRoot?: string | null) => export const hostedCreate = async (gameDirRoot?: string | null) => {
invokeWithSession<string>('plugin:install|hosted_create', { await requireHostedGameAccount()
return invokeWithSession<string>('plugin:install|hosted_create', {
gameDirRoot: gameDirRoot ?? null, gameDirRoot: gameDirRoot ?? null,
}) })
}
export const hostedBinding = (instanceId: string) => export const hostedBinding = (instanceId: string) =>
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId }) invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const hostedSync = (instanceId: string) => { export const hostedSync = async (instanceId: string) => {
await requireHostedGameAccount()
startHostedPackAttempt(instanceId) startHostedPackAttempt(instanceId)
return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId }) return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId })
} }
@@ -0,0 +1 @@
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { test } from 'node:test' import { test } from 'node:test'
import { isInstanceLaunching, launchOnce } from './instance-launch-state.ts' import { isInstanceLaunching, launchOnce } from './instance-launch-state.ts'
test('launches different instances concurrently and coalesces repeated pending clicks', async () => { test('launches different instances concurrently and coalesces repeated pending clicks', async () => {
@@ -1,18 +1,19 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
import { import {
setSkinSiteFrame,
resetSkinSiteSession,
receiveSkinSiteMessage, receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN, SKIN_SITE_ORIGIN,
} from '../composables/skin-site-session.ts' } from '../composables/skin-site-session.ts'
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
import { import {
type InstancePlayer,
onInstancePlayerChanged,
prepareInstancePlayer, prepareInstancePlayer,
registerInstancePlayerPicker, registerInstancePlayerPicker,
saveInstancePlayer, saveInstancePlayer,
onInstancePlayerChanged,
type InstancePlayer,
} from './instance-player.ts' } from './instance-player.ts'
test('instance player selection persists independently from the globally selected account', async () => { test('instance player selection persists independently from the globally selected account', async () => {
+89 -2
View File
@@ -9,7 +9,7 @@ import {
type ContentItem, type ContentItem,
type ContentOwner, type ContentOwner,
} from '@modrinth/ui' } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core' import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { isOfflineMode } from '@/composables/useNetworkStatus' import { isOfflineMode } from '@/composables/useNetworkStatus'
import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector' import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector'
@@ -18,8 +18,8 @@ import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategi
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types' import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
import { setLastGcLaunchReport } from '@/helpers/gc-notice' import { setLastGcLaunchReport } from '@/helpers/gc-notice'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs' import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { launchOnce } from '@/helpers/instance-launch-state' import { launchOnce } from '@/helpers/instance-launch-state'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments' import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
import { get_jre, get_memory_status } from '@/helpers/jre.js' import { get_jre, get_memory_status } from '@/helpers/jre.js'
import { get as getSettings } from '@/helpers/settings' import { get as getSettings } from '@/helpers/settings'
@@ -1124,3 +1124,90 @@ export async function check_symlink_capability(): Promise<SymlinkCapability> {
export async function allow_symlink_target(path: string): Promise<void> { export async function allow_symlink_target(path: string): Promise<void> {
return await invoke('allow_symlink_target', { path }) return await invoke('allow_symlink_target', { path })
} }
export type ScreenshotKey = { instance_id: string; file_name: string }
export type InstanceScreenshot = {
id: string
instance_id: string
instance_name: string
file_name: string
created_at: string
modified_at: number
group_id: string | null
path: string
url: string
}
export type ScreenshotGroup = { id: string; name: string }
export type ScreenshotGroupImport = ScreenshotGroup & { screenshot_ids: string[] }
export type ScreenshotGroupMembershipUpdate = { screenshot_id: string; group_id: string | null }
export async function list_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
}
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_all_screenshots')
}
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_synced_screenshots')
}
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
return await invoke('plugin:instance|instance_list_screenshot_groups')
}
export async function create_screenshot_group(
name: string,
screenshotIds: string[],
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_create_screenshot_group', {
name,
ids: screenshotIds,
})
}
export async function rename_screenshot_group(
id: string,
newName: string,
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
}
export async function delete_screenshot_group(id: string): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
}
export async function set_screenshot_group_memberships(
updates: ScreenshotGroupMembershipUpdate[],
): Promise<void> {
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
}
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
return await invoke('plugin:instance|instance_import_screenshot_groups', { groups })
}
export async function save_edited_screenshot(
key: ScreenshotKey,
pngBytes: Uint8Array,
mode: 'create_copy' | 'replace_edit',
): Promise<InstanceScreenshot> {
return await invoke('plugin:instance|instance_save_edited_screenshot', {
key,
bytes: Array.from(pngBytes),
mode,
})
}
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
}
export async function export_screenshots(keys: ScreenshotKey[], path: string): Promise<void> {
return await invoke('plugin:instance|instance_export_screenshots', { keys, path })
}
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
return await invoke('plugin:instance|instance_open_screenshot', { key })
}
export async function move_screenshots(
keys: ScreenshotKey[],
targetInstanceId: string,
): Promise<ScreenshotKey[]> {
return await invoke('plugin:instance|instance_move_screenshots', {
keys,
target: targetInstanceId,
})
}
export function getInstanceIconUrl(iconPath?: string | null): string | undefined {
return iconPath ? convertFileSrc(iconPath) : undefined
}
+3 -8
View File
@@ -64,14 +64,9 @@ export async function get_latest_log_cursor(instanceId, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { instanceId, cursor }) return await invoke('plugin:logs|logs_get_latest_log_cursor', { instanceId, cursor })
} }
/// Read Minecraft's logs/latest.log from a cursor. /// Get the authoritative sequenced live-log snapshot from the Rust ring buffer.
export async function get_minecraft_latest_log_cursor(instanceId, cursor) { export async function get_live_log_snapshot(instanceId) {
return await invoke('plugin:logs|logs_get_minecraft_latest_log_cursor', { instanceId, cursor }) return await invoke('plugin:logs|logs_get_live_log_snapshot', { instanceId })
}
/// Get all buffered live log lines for an instance from the Rust ring buffer
export async function get_live_log_buffer(instanceId) {
return await invoke('plugin:logs|logs_get_live_log_buffer', { instanceId })
} }
/// Clear the live log buffer for an instance on the Rust side /// Clear the live log buffer for an instance on the Rust side
@@ -82,20 +82,20 @@ test('notice query uses the target instance value and registered Tauri command',
assert.match(tauriSource, /tauri::generate_handler!\[[\s\S]*instance_get_post_upgrade_notice/) assert.match(tauriSource, /tauri::generate_handler!\[[\s\S]*instance_get_post_upgrade_notice/)
}) })
test('result uses Modrinth Card and Accordion for collapsed compatibility warnings', () => { test('result collections use Modrinth Card and Accordion for collapsed compatibility warnings', () => {
const source = readFileSync( const source = readFileSync(
new URL('../pages/instance/upgrade/UpgradeResultDetails.vue', import.meta.url), new URL('../pages/instance/upgrade/UpgradeResultCollections.vue', import.meta.url),
'utf8', 'utf8',
) )
assert.match(source, /<Card v-if="warningRows\.length"/) assert.match(source, /<Card v-if="warnings\.length"/)
assert.match(source, /<Accordion[\s\S]*:open-by-default="warningsExpandedByDefault"/) assert.match(source, /<Accordion[\s\S]*:open-by-default="warningsDefaultOpen"/)
assert.match(source, /<TriangleAlertIcon/) assert.match(source, /<TriangleAlertIcon/)
}) })
test('runtime icon references use names exported by the assets package', () => { test('runtime icon references use names exported by the assets package', () => {
const downloadsSource = readFileSync(new URL('../pages/Downloads.vue', import.meta.url), 'utf8') const downloadsSource = readFileSync(new URL('../pages/Downloads.vue', import.meta.url), 'utf8')
const resultSource = readFileSync( const resultSource = readFileSync(
new URL('../pages/instance/upgrade/UpgradeResultDetails.vue', import.meta.url), new URL('../pages/instance/upgrade/UpgradeResultCollections.vue', import.meta.url),
'utf8', 'utf8',
) )
const assetsSource = readFileSync( const assetsSource = readFileSync(
@@ -172,6 +172,7 @@ export type AppSettings = {
home_layout: HomeLayout home_layout: HomeLayout
minimal_home_instance_id: string | null minimal_home_instance_id: string | null
close_behavior: 'ask' | 'close' | 'lightweight' close_behavior: 'ask' | 'close' | 'lightweight'
ui_scale: number
home_widgets: HomeDashboardConfig | null home_widgets: HomeDashboardConfig | null
terracotta_public_nodes: string[] terracotta_public_nodes: string[]
@@ -213,6 +214,7 @@ type LegacyMirrorSettings = {
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings { function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
settings.close_behavior ??= 'ask' settings.close_behavior ??= 'ask'
settings.ui_scale ??= 100
const hasLegacySettings = const hasLegacySettings =
typeof settings.use_minecraft_mirror === 'boolean' && typeof settings.use_minecraft_mirror === 'boolean' &&
typeof settings.use_modrinth_mirror === 'boolean' && typeof settings.use_modrinth_mirror === 'boolean' &&
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { createTaggedModProgress } from './tagged-mod-progress.ts' import { createTaggedModProgress } from './tagged-mod-progress.ts'
const parent = (id = 'parent', fraction: number | null = 0, error = '') => ({ const parent = (id = 'parent', fraction: number | null = 0, error = '') => ({
+54
View File
@@ -0,0 +1,54 @@
import { invoke } from '@tauri-apps/api/core'
import { ref, watch } from 'vue'
/**
* 启动器界面缩放(百分比)。
* 通过 Tauri 原生 webview zoom(Rust 侧 set_zoom)应用,
* 缩放值持久化到后端设置数据库(settings.ui_scale)。
*/
const MIN_UI_SCALE = 50
const MAX_UI_SCALE = 200
const DEFAULT_UI_SCALE = 100
/** 把缩放值限制在允许范围内。 */
function clampUiScale(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_UI_SCALE
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Math.round(value)))
}
export const uiScale = ref(DEFAULT_UI_SCALE)
export function setUiScale(value: number): void {
uiScale.value = clampUiScale(value)
}
export function resetUiScale(): void {
setUiScale(DEFAULT_UI_SCALE)
}
export { DEFAULT_UI_SCALE, MAX_UI_SCALE, MIN_UI_SCALE }
/**
* 把缩放值应用到原生 webview。
* 同时保留 --ui-scale CSS 变量,供依赖它的样式/锚点使用。
*/
async function applyUiScale(value: number): Promise<void> {
const factor = value / 100
document.documentElement.style.setProperty('--ui-scale', String(factor))
try {
await invoke('plugin:settings|set_ui_scale', { factor })
} catch (error) {
console.warn('应用界面缩放失败', error)
}
}
/**
* 初始化缩放:应用当前值,并在变化时同步到原生 webview。
* 初值由调用方(App 初始化流程)在读取后端设置后通过 setUiScale 覆盖。
*/
export function installUiScale(): void {
void applyUiScale(uiScale.value)
watch(uiScale, (value) => {
void applyUiScale(value)
})
}
+3 -4
View File
@@ -4,9 +4,9 @@ import { invoke } from '@tauri-apps/api/core'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { isOfflineMode } from '@/composables/useNetworkStatus' import { isOfflineMode } from '@/composables/useNetworkStatus'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs' import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { get_full_path, resolveGcLaunchIntent } from '@/helpers/instance' import { get_full_path, resolveGcLaunchIntent } from '@/helpers/instance'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { openPath } from '@/helpers/utils' import { openPath } from '@/helpers/utils'
type BaseWorld = { type BaseWorld = {
@@ -20,7 +20,6 @@ type BaseWorld = {
export type WorldType = 'singleplayer' | 'server' export type WorldType = 'singleplayer' | 'server'
export type DisplayStatus = 'normal' | 'hidden' | 'favorite' export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
export type SingleplayerWorld = BaseWorld & { export type SingleplayerWorld = BaseWorld & {
type: 'singleplayer' type: 'singleplayer'
path: string path: string
@@ -251,7 +250,7 @@ export async function start_join_singleplayer_world(
world: string, world: string,
): Promise<unknown> { ): Promise<unknown> {
await prepareInstancePlayer(instanceId) await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession() if ((await getInstanceMode(instanceId)) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId) const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
return await invoke('plugin:worlds|start_join_singleplayer_world', { return await invoke('plugin:worlds|start_join_singleplayer_world', {
instanceId, instanceId,
@@ -264,7 +263,7 @@ export async function start_join_singleplayer_world(
export async function start_join_server(instanceId: string, address: string): Promise<unknown> { export async function start_join_server(instanceId: string, address: string): Promise<unknown> {
await prepareInstancePlayer(instanceId) await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession() if ((await getInstanceMode(instanceId)) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId) const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
return await invoke('plugin:worlds|start_join_server', { return await invoke('plugin:worlds|start_join_server', {
instanceId, instanceId,
File diff suppressed because it is too large. Load diff
@@ -805,6 +805,12 @@
"app.appearance-settings.transparent-background.title": { "app.appearance-settings.transparent-background.title": {
"message": "透明背景" "message": "透明背景"
}, },
"app.appearance-settings.ui-scale.title": {
"message": "启动器缩放"
},
"app.appearance-settings.ui-scale.description": {
"message": "缩放整个启动器界面。缩小后能显示更多内容,适合内嵌网页被裁切的情况。"
},
"app.appearance-settings.unknown-pack-warning.description": { "app.appearance-settings.unknown-pack-warning.description": {
"message": "如果你尝试安装一个未托管在 Modrinth 上的 Modrinth 整合包文件(.mrpack),我们会确保你在安装之前了解相关风险。" "message": "如果你尝试安装一个未托管在 Modrinth 上的 Modrinth 整合包文件(.mrpack),我们会确保你在安装之前了解相关风险。"
}, },
@@ -6055,6 +6061,36 @@
"app.onboarding.welcome.title": { "app.onboarding.welcome.title": {
"message": "一切就绪" "message": "一切就绪"
}, },
"app.onboarding.home-intro.title": {
"message": "欢迎回家"
},
"app.onboarding.home-intro.description": {
"message": "这里是你的主页,展示你的实例、最近活动和快速开始游戏的入口。"
},
"app.onboarding.account-login.title": {
"message": "登录 StarLight"
},
"app.onboarding.account-login.description": {
"message": "在这里登录 StarLight 皮肤站,即可使用皮肤站玩家、安装托管整合包并同步你的账号。"
},
"app.onboarding.announcement.title": {
"message": "最新公告"
},
"app.onboarding.announcement.description": {
"message": "打开侧栏的公告入口,查看最新消息、服规与服务器通知。"
},
"app.onboarding.create-starlight.title": {
"message": "创建 StarLight 实例"
},
"app.onboarding.create-starlight.description": {
"message": "点击这里的“创建新实例”。StarLight 实例会安装服务器指定的整合包与版本,并在每次启动前保持更新。"
},
"app.onboarding.settings-updates.title": {
"message": "保持最新"
},
"app.onboarding.settings-updates.description": {
"message": "“更新”页用于设置更新通道、检查启动器新版本并查看更新日志。"
},
"app.project.curseforge.loading": { "app.project.curseforge.loading": {
"message": "正在加载 CurseForge 项目…" "message": "正在加载 CurseForge 项目…"
}, },
@@ -10422,5 +10458,227 @@
}, },
"app.settings.about.easteregg.game-title": { "app.settings.about.easteregg.game-title": {
"message": "彩蛋小游戏" "message": "彩蛋小游戏"
},
"app.home.launch-progress.empty": {
"message": "启动游戏后,这里会显示进度。"
},
"app.home.launch-progress.title": {
"message": "启动进度"
},
"app.screenshots.copied": {
"message": "已复制"
},
"app.screenshots.copy": {
"message": "复制图像"
},
"app.screenshots.delete-description": {
"message": "永久删除 {name}?此操作无法撤销。"
},
"app.screenshots.delete-success": {
"message": "截图已删除"
},
"app.screenshots.delete-title": {
"message": "删除截图"
},
"app.screenshots.deselect": {
"message": "取消选择 {name}"
},
"app.screenshots.edit": {
"message": "编辑截图"
},
"app.screenshots.empty-description": {
"message": "你在游戏中拍摄的截图会显示在这里。"
},
"app.screenshots.empty-heading": {
"message": "还没有截图"
},
"app.screenshots.error-heading": {
"message": "加载截图失败"
},
"app.screenshots.go-to-instance": {
"message": "前往实例"
},
"app.screenshots.group-by": {
"message": "分组方式"
},
"app.screenshots.group.all-screenshots": {
"message": "全部截图"
},
"app.screenshots.group.collapse": {
"message": "折叠分组"
},
"app.screenshots.group.custom": {
"message": "自定义分组"
},
"app.screenshots.group.date": {
"message": "日期"
},
"app.screenshots.group.delete": {
"message": "删除分组"
},
"app.screenshots.group.delete-description": {
"message": "此分组中的截图将变为未分组。"
},
"app.screenshots.group.edit": {
"message": "编辑分组名称"
},
"app.screenshots.group.empty": {
"message": "拖放以添加截图。"
},
"app.screenshots.group.expand": {
"message": "展开分组"
},
"app.screenshots.group.instance": {
"message": "实例"
},
"app.screenshots.group.new": {
"message": "新建分组"
},
"app.screenshots.group.none": {
"message": "不分组"
},
"app.screenshots.group.this-month": {
"message": "本月"
},
"app.screenshots.group.this-week": {
"message": "本周"
},
"app.screenshots.group.today": {
"message": "今天"
},
"app.screenshots.group.ungrouped": {
"message": "未分组"
},
"app.screenshots.group.yesterday": {
"message": "昨天"
},
"app.screenshots.heading": {
"message": "截图"
},
"app.screenshots.more-actions": {
"message": "更多操作"
},
"app.screenshots.no-results-description": {
"message": "请尝试其他搜索或筛选条件。"
},
"app.screenshots.no-results-heading": {
"message": "没有匹配的截图"
},
"app.screenshots.preview.instance-and-date": {
"message": "{instance} · {date}"
},
"app.screenshots.search": {
"message": "搜索"
},
"app.screenshots.select": {
"message": "选择 {name}"
},
"app.screenshots.selection.aria-label": {
"message": "已选中的截图"
},
"app.screenshots.selection.delete-description": {
"message": "删除 {count, plural, one {# 张截图} other {# 张截图}}?此操作无法撤销。"
},
"app.screenshots.selection.delete-success": {
"message": "{count, plural, one {已删除 # 张截图} other {已删除 # 张截图}}"
},
"app.screenshots.selection.delete-title": {
"message": "删除选中的截图"
},
"app.screenshots.selection.export-zip": {
"message": "导出 ZIP"
},
"app.screenshots.selection.global-export-filename": {
"message": "Modrinth screenshots.zip"
},
"app.screenshots.selection.instance-export-filename": {
"message": "{instance} screenshots.zip"
},
"app.screenshots.selection.remove-from-group": {
"message": "从分组中移除"
},
"app.screenshots.selection.selected-count": {
"message": "已选中 {count} 张"
},
"app.screenshots.selection.zip-archive": {
"message": "ZIP 压缩包"
},
"app.screenshots.show-in-folder": {
"message": "在文件夹中显示"
},
"app.screenshots.sort-by": {
"message": "排序方式"
},
"app.screenshots.sort.name": {
"message": "名称"
},
"app.screenshots.sort.newest": {
"message": "最新"
},
"app.screenshots.sort.oldest": {
"message": "最旧"
},
"app.settings.about.copyright": {
"message": "版权所有 © StarLight 保留所有权利。"
},
"app.settings.about.game.best": {
"message": "最佳:{score}"
},
"app.settings.about.game.best-label": {
"message": "最佳"
},
"app.settings.about.game.completed": {
"message": "下界之星!"
},
"app.settings.about.game.exit": {
"message": "退出游戏"
},
"app.settings.about.game.game-over": {
"message": "群星坠落!"
},
"app.settings.about.game.highest-level": {
"message": "最高:等级 {level}"
},
"app.settings.about.game.level-label": {
"message": "最高等级"
},
"app.settings.about.game.new-record": {
"message": "新纪录!"
},
"app.settings.about.game.overtime": {
"message": "涨潮!"
},
"app.settings.about.game.overtime-detail": {
"message": "潮水涌回——继续合成!"
},
"app.settings.about.game.overtimes-label": {
"message": "加时次数"
},
"app.settings.about.game.restart": {
"message": "重新开始"
},
"app.settings.about.game.score": {
"message": "得分:{score}"
},
"app.settings.about.game.score-label": {
"message": "得分"
},
"app.settings.about.game.settle": {
"message": "稳定"
},
"app.settings.about.game.settle-title": {
"message": "本局已稳定!"
},
"app.settings.about.game.tap-to-drop": {
"message": "点击投放一颗星"
},
"app.settings.about.game.tide-hint": {
"message": "天空正在坠落——继续合成!"
},
"app.settings.about.game.title": {
"message": "Starlight 合成"
},
"app.starlight-skin.frame-title": {
"message": "StarLight 皮肤站"
} }
} }
+4 -4
View File
@@ -3093,7 +3093,7 @@ provideBrowseManager({
<template> <template>
<div data-onboarding-id="browse-content" class="flex flex-col gap-3 p-6"> <div data-onboarding-id="browse-content" class="flex flex-col gap-3 p-6">
<BrowsePageLayout v-if="!isWorldMapBrowse || curseForgeCapability.configured"> <BrowsePageLayout>
<template #nav-tabs-actions> <template #nav-tabs-actions>
<ButtonStyled size="large" type="transparent"> <ButtonStyled size="large" type="transparent">
<button :disabled="translationLoading" @click="toggleTranslation"> <button :disabled="translationLoading" @click="toggleTranslation">
@@ -3193,14 +3193,14 @@ provideBrowseManager({
{{ formatMessage(messages.fuzzySearchSuffix) }} {{ formatMessage(messages.fuzzySearchSuffix) }}
</p> </p>
</div> </div>
</template>
</BrowsePageLayout>
<EmptyState <EmptyState
v-else v-if="isWorldMapBrowse && !curseForgeCapability.configured"
type="empty-inbox" type="empty-inbox"
:heading="formatMessage(messages.mapsUnavailable)" :heading="formatMessage(messages.mapsUnavailable)"
:description="formatMessage(messages.mapsUnavailableDescription)" :description="formatMessage(messages.mapsUnavailableDescription)"
/> />
</template>
</BrowsePageLayout>
<CreationFlowModal <CreationFlowModal
v-if="isServerContext && projectType === 'modpack'" v-if="isServerContext && projectType === 'modpack'"
ref="serverSetupModalRef" ref="serverSetupModalRef"
+5 -7
View File
@@ -3,11 +3,12 @@ import { FolderOpenIcon, LeftArrowIcon, SparklesIcon } from '@modrinth/assets'
import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui' import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
import { inject, ref, watch } from 'vue' import { inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import HostedGameDirModal from '@/components/instance/HostedGameDirModal.vue' import HostedGameDirModal from '@/components/instance/HostedGameDirModal.vue'
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue' import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
import type { InstanceMode } from '@/helpers/hosted-packs' import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import { useHostedCreation } from '@/composables/useHostedCreation' import { useHostedCreation } from '@/composables/useHostedCreation'
import type { InstanceMode } from '@/helpers/hosted-packs'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const router = useRouter() const router = useRouter()
@@ -187,8 +188,8 @@ function handleImportExisting() {
/> />
<BigOptionButton <BigOptionButton
data-onboarding-id="creation-method-import"
v-if="instanceMode === 'local'" v-if="instanceMode === 'local'"
data-onboarding-id="creation-method-import"
:icon="FolderOpenIcon" :icon="FolderOpenIcon"
:title="formatMessage(messages.importTitle)" :title="formatMessage(messages.importTitle)"
:description="formatMessage(messages.importDescription)" :description="formatMessage(messages.importDescription)"
@@ -197,10 +198,7 @@ function handleImportExisting() {
/> />
</div> </div>
<HostedGameDirModal <HostedGameDirModal ref="hostedGameDirModal" @confirm="installHostedWithGameDir" />
ref="hostedGameDirModal"
@confirm="installHostedWithGameDir"
/>
<HostedPackProgress :instance-id="createdInstance" :active="installing" /> <HostedPackProgress :instance-id="createdInstance" :active="installing" />
<p v-if="installError" class="m-0 text-red" role="alert">{{ installError }}</p> <p v-if="installError" class="m-0 text-red" role="alert">{{ installError }}</p>
<p v-if="instanceMode === 'local'" class="m-0 text-sm text-secondary"> <p v-if="instanceMode === 'local'" class="m-0 text-sm text-secondary">
+2 -2
View File
@@ -10,7 +10,7 @@ import { computed, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getActivePlayerName } from '@/components/home/home-utils' import { getActivePlayerName } from '@/components/home/home-utils'
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue' import HomeAnnouncementEntry from '@/components/home/HomeAnnouncementEntry.vue'
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue' import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue' import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue' import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
@@ -202,7 +202,7 @@ onUnmounted(() => {
> >
<HomeLaunchProgress /> <HomeLaunchProgress />
<HomePlayInsights /> <HomePlayInsights />
<HomeDailyChallenge /> <HomeAnnouncementEntry />
<HomeMinecraftNews /> <HomeMinecraftNews />
</div> </div>
</Teleport> </Teleport>
@@ -259,7 +259,7 @@ onMounted(() => {
/> />
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button class="start-button" @click="startTranslation" :disabled="!canStart"> <button class="start-button" :disabled="!canStart" @click="startTranslation">
<PlayIcon />{{ formatMessage(messages.start) }} <PlayIcon />{{ formatMessage(messages.start) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
+1 -1
View File
@@ -33,11 +33,11 @@ import {
defineMessages, defineMessages,
DropdownSelect, DropdownSelect,
injectNotificationManager, injectNotificationManager,
type MessageDescriptor,
PopoutMenu, PopoutMenu,
Slider, Slider,
StyledInput, StyledInput,
Toggle, Toggle,
type MessageDescriptor,
useRelativeTime, useRelativeTime,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
+1 -3
View File
@@ -2,10 +2,9 @@
import { UsersIcon } from '@modrinth/assets' import { UsersIcon } from '@modrinth/assets'
import { defineMessages, NavTabs, useVIntl } from '@modrinth/ui' import { defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
import { computed } from 'vue' import { computed } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router' import { RouterView, useRouter } from 'vue-router'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter() const router = useRouter()
const messages = defineMessages({ const messages = defineMessages({
@@ -13,7 +12,6 @@ const messages = defineMessages({
roomsTab: { id: 'app.multiplayer.tab.rooms', defaultMessage: 'Rooms' }, roomsTab: { id: 'app.multiplayer.tab.rooms', defaultMessage: 'Rooms' },
}) })
const activeTab = computed(() => (route.path.startsWith('/multiplayer/rooms') ? 'rooms' : 'rooms'))
const tabLinks = computed(() => [ const tabLinks = computed(() => [
{ label: formatMessage(messages.roomsTab), href: '/multiplayer/rooms', icon: UsersIcon }, { label: formatMessage(messages.roomsTab), href: '/multiplayer/rooms', icon: UsersIcon },
]) ])
@@ -0,0 +1,11 @@
<script setup lang="ts">
import ScreenshotsPage from '@/components/ui/screenshots-page/index.vue'
defineOptions({ name: 'ScreenshotsPage' })
</script>
<template>
<div class="box-border h-full p-6">
<ScreenshotsPage show-heading />
</div>
</template>
+10 -10
View File
@@ -163,7 +163,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else-if="!isServerInstance" color="brand" size="large"> <ButtonStyled v-else-if="!isServerInstance" color="brand" size="large">
<button @click="startInstance('InstancePage')"> <button @click="startInstance">
<PlayIcon /> <PlayIcon />
{{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }} {{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }}
</button> </button>
@@ -184,7 +184,7 @@
}, },
{ {
id: 'launch_instance', id: 'launch_instance',
action: () => startInstance('InstancePage'), action: startInstance,
}, },
]" ]"
> >
@@ -204,7 +204,7 @@
</ButtonStyled> </ButtonStyled>
</div> </div>
<ButtonStyled v-if="playing" color="red" size="large"> <ButtonStyled v-if="playing" color="red" size="large">
<button :disabled="stopping || loading" @click="stopInstance('InstancePage')"> <button :disabled="stopping || loading" @click="stopInstance">
<StopCircleIcon /> <StopCircleIcon />
{{ formatMessage(stopping ? messages.stopping : messages.stopAll) }} {{ formatMessage(stopping ? messages.stopping : messages.stopAll) }}
</button> </button>
@@ -294,7 +294,7 @@
:is-server-instance="isServerInstance" :is-server-instance="isServerInstance"
:open-settings="() => settingsModal?.show(1)" :open-settings="() => settingsModal?.show(1)"
@play="updatePlayState" @play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')" @stop="stopInstance"
></component> ></component>
</Suspense> </Suspense>
</template> </template>
@@ -400,9 +400,9 @@ import {
getFreshCachedServerStatus, getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query' } from '@/composables/instances/use-server-status-query'
import { useInstanceConsole } from '@/composables/useInstanceConsole' import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { useInstanceMode } from '@/composables/useInstanceMode'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError' import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { useInstanceMode } from '@/composables/useInstanceMode'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice' import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss' import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
import { get_project_v3 } from '@/helpers/cache.js' import { get_project_v3 } from '@/helpers/cache.js'
@@ -421,8 +421,8 @@ import {
run, run,
} from '@/helpers/instance' } from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons' import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { get_by_instance_id } from '@/helpers/process'
import { isInstanceLaunching } from '@/helpers/instance-launch-state' import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js' import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds' import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
@@ -733,7 +733,7 @@ watch(
const options = ref<InstanceType<typeof ContextMenu> | null>(null) const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => { const startInstance = async () => {
if (!instance.value || loading.value) return if (!instance.value || loading.value) return
const id = props.id const id = props.id
const name = instance.value.name const name = instance.value.name
@@ -780,7 +780,7 @@ watch(
{ immediate: true }, { immediate: true },
) )
const stopInstance = async (context: string) => { const stopInstance = async () => {
stopping.value = true stopping.value = true
await kill(props.id).catch(handleError) await kill(props.id).catch(handleError)
stopping.value = false stopping.value = false
@@ -882,10 +882,10 @@ const handleRightClick = (event: MouseEvent) => {
const handleOptionsClick = async (args: { option: string; item: unknown }) => { const handleOptionsClick = async (args: { option: string; item: unknown }) => {
switch (args.option) { switch (args.option) {
case 'play': case 'play':
await startInstance('InstancePageContextMenu') await startInstance()
break break
case 'stop': case 'stop':
await stopInstance('InstancePageContextMenu') await stopInstance()
break break
case 'add_content': case 'add_content':
await router.push({ await router.push({
+10 -32
View File
@@ -86,13 +86,16 @@ const {
getHistoricalContent, getHistoricalContent,
invalidate, invalidate,
clearLive, clearLive,
refreshLive, appendLive,
flushLive, resyncLive,
acceptsProcessLogs,
resetLive,
} = useInstanceConsole(instanceId.value) } = useInstanceConsole(instanceId.value)
await hydrate() const unlistenLog = await log_listener((payload) => {
if (payload.instance_id !== instanceId.value) return
void appendLive(payload)?.catch(console.warn)
})
await hydrate().catch(handleError)
function buildLogList(rawLogs) { function buildLogList(rawLogs) {
return [ return [
@@ -219,28 +222,16 @@ if (!props.playing) {
void analyseForCrash() void analyseForCrash()
} }
const unlistenLog = await log_listener((payload) => {
if (payload.instance_id !== instanceId.value) return
if (!acceptsProcessLogs()) return
if (payload.type === 'log4j') {
liveConsole.addLog4jEvent(payload)
} else if (payload.type === 'legacy') {
void liveConsole.addLegacyLog(payload.message)
}
})
const unlistenProcesses = await process_listener(async (e) => { const unlistenProcesses = await process_listener(async (e) => {
if (e.instance_id !== instanceId.value) return if (e.instance_id !== instanceId.value) return
if (e.event === 'launched') { if (e.event === 'launched') {
resetLive() await resyncLive().catch(console.warn)
clearCrashAnalysis() clearCrashAnalysis()
invalidate() invalidate()
selectedLogIndex.value = 0 selectedLogIndex.value = 0
} }
if (e.event === 'finished') { if (e.event === 'finished') {
await refreshLive().catch(console.warn) await resyncLive().catch(console.warn)
flushLive()
invalidate() invalidate()
const freshLogs = await getHistoricalLogs() const freshLogs = await getHistoricalLogs()
logs.value = buildLogList(freshLogs) logs.value = buildLogList(freshLogs)
@@ -248,20 +239,7 @@ const unlistenProcesses = await process_listener(async (e) => {
} }
}) })
// Keep following the file even when a mod stops writing to stdout after startup.
// Schedule after each read so slow disk/IPC cannot pile up concurrent requests.
let disposed = false
let refreshTimer
async function followLatestLog() {
await refreshLive().catch(console.warn)
if (!props.playing) flushLive()
if (!disposed) refreshTimer = setTimeout(followLatestLog, 1000)
}
void followLatestLog()
onUnmounted(() => { onUnmounted(() => {
disposed = true
clearTimeout(refreshTimer)
unlistenLog() unlistenLog()
unlistenProcesses() unlistenProcesses()
}) })
@@ -1408,7 +1408,8 @@ async function getUpdaterProjectVersions(
if (!versions) { if (!versions) {
versions = (await get_project_versions(projectId).catch(() => null)) as versions = (await get_project_versions(projectId).catch(() => null)) as
Labrinth.Versions.v2.Version[] | null | Labrinth.Versions.v2.Version[]
| null
} }
if (!versions && fetchError) { if (!versions && fetchError) {
@@ -1719,9 +1720,6 @@ async function applyToggleDisableBatch(items: ContentItem[], enabled: boolean) {
resultById, resultById,
false, false,
) )
for (const operation of operations) {
}
} catch (error) { } catch (error) {
for (const operation of operations) { for (const operation of operations) {
operation.optimisticPath = operation.originalFilePath operation.optimisticPath = operation.originalFilePath
@@ -0,0 +1,57 @@
import { queryOptions } from '@tanstack/vue-query'
import {
list,
list_all_screenshots,
list_screenshot_groups,
list_screenshots,
list_synced_screenshots,
} from '@/helpers/instance'
export const instanceKeys = {
all: ['instances'] as const,
list: () => ['instances', 'list'] as const,
}
export function instanceListQueryOptions() {
return queryOptions({
queryKey: instanceKeys.list(),
queryFn: list,
})
}
export const screenshotKeys = {
all: ['screenshots'] as const,
global: () => ['screenshots', 'global'] as const,
synced: () => ['screenshots', 'synced'] as const,
instance: (instanceId: string) => ['screenshots', 'instance', instanceId] as const,
groups: () => ['screenshots', 'groups'] as const,
}
export function instanceScreenshotsQueryOptions(instanceId: string) {
return queryOptions({
queryKey: screenshotKeys.instance(instanceId),
queryFn: () => list_screenshots(instanceId),
})
}
export function allScreenshotsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.global(),
queryFn: list_all_screenshots,
})
}
export function syncedScreenshotsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.synced(),
queryFn: list_synced_screenshots,
})
}
export function screenshotGroupsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.groups(),
queryFn: list_screenshot_groups,
})
}
@@ -845,7 +845,7 @@ async function installSelected(fileId: string | null) {
} }
if (isWorldMap.value) { if (isWorldMap.value) {
installing.value = true installing.value = true
await installCurseForgeWorld(project.value.id, fileId, instanceId.value, 'ProjectPage', () => { await installCurseForgeWorld(project.value.id, fileId, instanceId.value, () => {
installing.value = false installing.value = false
}).catch((error) => { }).catch((error) => {
installing.value = false installing.value = false
@@ -414,8 +414,8 @@ import {
ProjectSidebarServerInfo, ProjectSidebarServerInfo,
ProjectSidebarTags, ProjectSidebarTags,
requestInstall, requestInstall,
SelectedProjectsFloatingBar,
ScrollToTopButton, ScrollToTopButton,
SelectedProjectsFloatingBar,
usesTargetGameVersion, usesTargetGameVersion,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
@@ -567,7 +567,7 @@ const messages = defineMessages({
favoritesLoading: { favoritesLoading: {
id: 'app.content-favorites.loading', id: 'app.content-favorites.loading',
defaultMessage: 'Updating favorites…', defaultMessage: 'Updating favorites…',
}, },
viewDependents: { viewDependents: {
id: 'project.actions.view-dependents', id: 'project.actions.view-dependents',
defaultMessage: 'View dependents', defaultMessage: 'View dependents',
@@ -72,12 +72,7 @@
</template> </template>
<script setup> <script setup>
import { import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
CheckIcon,
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
} from '@modrinth/assets'
import { import {
ButtonStyled, ButtonStyled,
commonMessages, commonMessages,
@@ -108,7 +103,7 @@ const messages = defineMessages({
}, },
}) })
const props = defineProps({ defineProps({
project: { project: {
type: Object, type: Object,
default: () => {}, default: () => {},
@@ -517,7 +517,6 @@ export interface ContentInstallContext {
projectId: string | number, projectId: string | number,
fileId?: string | number | null, fileId?: string | number | null,
instanceId?: string | null, instanceId?: string | null,
source?: string,
callback?: ContentInstallCallback, callback?: ContentInstallCallback,
) => Promise<void> ) => Promise<void>
installingItems: Ref<Map<string, ContentItem[]>> installingItems: Ref<Map<string, ContentItem[]>>
@@ -1884,7 +1883,6 @@ export function createContentInstall(opts: {
markInstanceContentChanged(instance.id) markInstanceContentChanged(instance.id)
incompatibilityWarningModalRef?.hide() incompatibilityWarningModalRef?.hide()
removeInstallingItems(instance.id, [project.id]) removeInstallingItems(instance.id, [project.id])
} }
function handleIncompatibilityWarningCancel() { function handleIncompatibilityWarningCancel() {
@@ -2056,7 +2054,6 @@ export function createContentInstall(opts: {
: `/instance/${encodeURIComponent(id)}`, : `/instance/${encodeURIComponent(id)}`,
) )
settleCurrentCallback(version.id, installedProjectIds) settleCurrentCallback(version.id, installedProjectIds)
modalRef?.hide() modalRef?.hide()
} catch (err) { } catch (err) {
@@ -2116,10 +2113,14 @@ export function createContentInstall(opts: {
if (project.project_type === 'modpack') { if (project.project_type === 'modpack') {
if (shouldShowInstallTargetModal) hideContentInstallModal() if (shouldShowInstallTargetModal) hideContentInstallModal()
const [versions, packs] = await Promise.all([ const [versions, packs] = await Promise.all([
get_version_many(project.versions, 'must_revalidate') as Promise<Labrinth.Versions.v2.Version[]>, get_version_many(project.versions, 'must_revalidate') as Promise<
Labrinth.Versions.v2.Version[]
>,
list(), list(),
]) ])
const sortedVersions = versions.sort((a, b) => dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf()) const sortedVersions = versions.sort(
(a, b) => dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
)
pendingModpackInstall = { pendingModpackInstall = {
project, project,
version: versionId ?? sortedVersions[0]?.id ?? '', version: versionId ?? sortedVersions[0]?.id ?? '',
@@ -2132,7 +2133,10 @@ export function createContentInstall(opts: {
sortedVersions.map((version) => [ sortedVersions.map((version) => [
version.id, version.id,
packs packs
.filter((pack) => pack.link?.project_id === project.id && pack.link?.version_id === version.id) .filter(
(pack) =>
pack.link?.project_id === project.id && pack.link?.version_id === version.id,
)
.map((pack) => ({ id: pack.id, name: pack.name })), .map((pack) => ({ id: pack.id, name: pack.name })),
]), ]),
) )
@@ -2391,7 +2395,6 @@ export function createContentInstall(opts: {
projectId: string | number, projectId: string | number,
fileId?: string | number | null, fileId?: string | number | null,
instanceId?: string | null, instanceId?: string | null,
source: string = 'unknown',
callback: ContentInstallCallback = () => {}, callback: ContentInstallCallback = () => {},
) { ) {
const modalSessionId = instanceId ? null : beginInstallSession(callback) const modalSessionId = instanceId ? null : beginInstallSession(callback)
@@ -2486,11 +2489,10 @@ export function createContentInstall(opts: {
projectId: string | number, projectId: string | number,
fileId?: string | number | null, fileId?: string | number | null,
instanceId?: string | null, instanceId?: string | null,
source: string = 'unknown',
callback: ContentInstallCallback = () => {}, callback: ContentInstallCallback = () => {},
) { ) {
return guardInstallRequest( return guardInstallRequest(
() => installCurseForgeWorldInternal(projectId, fileId, instanceId, source, callback), () => installCurseForgeWorldInternal(projectId, fileId, instanceId, callback),
callback, callback,
) )
} }
@@ -2532,8 +2534,7 @@ export function createContentInstall(opts: {
handleCurseForgeManualDownloadsImported, handleCurseForgeManualDownloadsImported,
async handleModpackInstall(versionId: string, name: string) { async handleModpackInstall(versionId: string, name: string) {
if (!pendingModpackInstall) return if (!pendingModpackInstall) return
const { project, source, callback, createInstanceCallback, provider } = const { project, source, callback, createInstanceCallback, provider } = pendingModpackInstall
pendingModpackInstall
pendingModpackInstall = null pendingModpackInstall = null
if (provider === 'curseforge') { if (provider === 'curseforge') {
const numericProjectId = Number(project.id.replace(/^curseforge:/, '')) const numericProjectId = Number(project.id.replace(/^curseforge:/, ''))
+8
View File
@@ -101,6 +101,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Starlight skin' }], breadcrumb: [{ name: 'Starlight skin' }],
}, },
}, },
{
path: '/screenshots',
name: 'Screenshots',
component: () => import('@/pages/Screenshots.vue'),
meta: {
breadcrumb: [{ name: 'Screenshots' }],
},
},
{ {
path: '/multiplayer', path: '/multiplayer',
name: 'Multiplayer', name: 'Multiplayer',
+1
View File
@@ -14,6 +14,7 @@ export const DEFAULT_FEATURE_FLAGS = {
skip_non_essential_warnings: false, skip_non_essential_warnings: false,
skip_unknown_pack_warning: false, skip_unknown_pack_warning: false,
i18n_debug: false, i18n_debug: false,
multiplayer_in_app: false,
show_instance_play_time: true, show_instance_play_time: true,
page_transitions: true, page_transitions: true,
advanced_filters_collapsed: true, advanced_filters_collapsed: true,
+2 -8
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging # The actual version is set by the theseus-build workflow on tagging
version = "1.0.0-beta12" version = "1.0.0-beta15"
edition.workspace = true edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher" description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red" homepage = "https://www.ghs.red"
@@ -56,13 +56,7 @@ zip = { workspace = true }
[dependencies.tauri] [dependencies.tauri]
workspace = true workspace = true
features = [ features = ["devtools", "macos-private-api", "protocol-asset", "tray-icon", "unstable"]
"devtools",
"macos-private-api",
"protocol-asset",
"tray-icon",
"unstable"
]
[build-dependencies] [build-dependencies]
cc = { workspace = true } cc = { workspace = true }
+1 -2
View File
@@ -292,8 +292,7 @@ fn main() {
"logs_delete_logs", "logs_delete_logs",
"logs_delete_logs_by_filename", "logs_delete_logs_by_filename",
"logs_get_latest_log_cursor", "logs_get_latest_log_cursor",
"logs_get_minecraft_latest_log_cursor", "logs_get_live_log_snapshot",
"logs_get_live_log_buffer",
"logs_clear_live_log_buffer", "logs_clear_live_log_buffer",
"logs_analyze_crash", "logs_analyze_crash",
"logs_get_crash_analysis_ai_settings", "logs_get_crash_analysis_ai_settings",
+1
View File
@@ -547,6 +547,7 @@ fn keyring_error(
.into() .into()
} }
#[cfg(test)]
fn parse_custom_uuid(uuid: Option<String>) -> Result<Option<uuid::Uuid>> { fn parse_custom_uuid(uuid: Option<String>) -> Result<Option<uuid::Uuid>> {
let Some(uuid) = uuid else { let Some(uuid) = uuid else {
return Ok(None); return Ok(None);
+1 -1
View File
@@ -9,7 +9,7 @@ use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{Emitter, Runtime}; use tauri::{Emitter, Runtime};
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
use theseus::instance::get_full_path; use theseus::instance::{get_full_path, list};
const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed"; const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed";
+6
View File
@@ -16,6 +16,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
hosted_default, hosted_default,
hosted_set_session, hosted_set_session,
hosted_has_game_account,
hosted_create, hosted_create,
hosted_binding, hosted_binding,
hosted_sync, hosted_sync,
@@ -65,6 +66,11 @@ pub async fn hosted_default() -> Result<theseus::pack::hosted::Publication> {
Ok(theseus::pack::hosted::default_publication().await?) Ok(theseus::pack::hosted::default_publication().await?)
} }
#[tauri::command]
pub async fn hosted_has_game_account() -> Result<bool> {
Ok(theseus::pack::hosted::has_game_account().await?)
}
#[tauri::command] #[tauri::command]
pub async fn hosted_create(game_dir_root: Option<String>) -> Result<String> { pub async fn hosted_create(game_dir_root: Option<String>) -> Result<String> {
Ok(theseus::pack::hosted::create(game_dir_root).await?) Ok(theseus::pack::hosted::create(game_dir_root).await?)
@@ -6,6 +6,9 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tauri::Manager;
use tauri_plugin_fs::FsExt;
use tauri_plugin_opener::OpenerExt;
use theseus::data::{ use theseus::data::{
AppliedContentSetPatch, ContentItem, Dependency, AppliedContentSetPatch, ContentItem, Dependency,
EditInstance as CoreEditInstance, InstanceInstallCandidate, EditInstance as CoreEditInstance, InstanceInstallCandidate,
@@ -103,6 +106,20 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_edit_icon, instance_edit_icon,
instance_export_mrpack, instance_export_mrpack,
instance_get_pack_export_candidates, instance_get_pack_export_candidates,
instance_list_screenshots,
instance_list_all_screenshots,
instance_list_synced_screenshots,
instance_save_edited_screenshot,
instance_list_screenshot_groups,
instance_create_screenshot_group,
instance_rename_screenshot_group,
instance_delete_screenshot_group,
instance_set_screenshot_group_memberships,
instance_import_screenshot_groups,
instance_delete_screenshots,
instance_export_screenshots,
instance_move_screenshots,
instance_open_screenshot,
]) ])
.build() .build()
} }
@@ -1490,3 +1507,171 @@ pub async fn instance_edit_icon(
theseus::instance::edit_icon(instance_id, icon_path).await?; theseus::instance::edit_icon(instance_id, icon_path).await?;
Ok(()) Ok(())
} }
#[derive(Serialize, Debug, Clone)]
pub struct InstanceScreenshot {
pub id: String,
pub instance_id: String,
pub instance_name: String,
pub file_name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub modified_at: i64,
pub group_id: Option<String>,
pub path: PathBuf,
pub url: String,
}
fn serialize_screenshot<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
s: theseus::instance::InstanceScreenshot,
) -> Result<InstanceScreenshot> {
if let Some(parent) = s.path.parent() {
crate::api::files::ensure_browsable(app, parent);
}
let mut url = super::utils::tauri_convert_file_src(&s.path)?;
app.asset_protocol_scope().allow_file(&s.path)?;
app.fs_scope().allow_file(&s.path)?;
url.query_pairs_mut()
.append_pair("revision", &s.modified_at.to_string());
Ok(InstanceScreenshot {
id: s.id,
instance_id: s.instance_id,
instance_name: s.instance_name,
file_name: s.file_name,
created_at: s.created_at,
modified_at: s.modified_at,
group_id: s.group_id,
path: s.path,
url: url.to_string(),
})
}
fn serialize_screenshots<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
items: Vec<theseus::instance::InstanceScreenshot>,
) -> Result<Vec<InstanceScreenshot>> {
items
.into_iter()
.map(|s| serialize_screenshot(app, s))
.collect()
}
#[tauri::command]
pub async fn instance_list_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
instance_id: &str,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_screenshots(instance_id).await?,
)
}
#[tauri::command]
pub async fn instance_list_all_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_all_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_list_synced_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_synced_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_save_edited_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
bytes: Vec<u8>,
mode: theseus::instance::ScreenshotEditSaveMode,
) -> Result<InstanceScreenshot> {
serialize_screenshot(
&app,
theseus::instance::save_edited_screenshot(key, bytes, mode).await?,
)
}
#[tauri::command]
pub async fn instance_list_screenshot_groups()
-> Result<Vec<theseus::instance::ScreenshotGroup>> {
Ok(theseus::instance::list_screenshot_groups().await?)
}
#[tauri::command]
pub async fn instance_create_screenshot_group(
name: String,
ids: Vec<String>,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::create_screenshot_group(name, ids).await?)
}
#[tauri::command]
pub async fn instance_rename_screenshot_group(
id: String,
new_name: String,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::rename_screenshot_group(id, new_name).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshot_group(id: String) -> Result<()> {
Ok(theseus::instance::delete_screenshot_group(id).await?)
}
#[tauri::command]
pub async fn instance_set_screenshot_group_memberships(
updates: Vec<theseus::instance::ScreenshotGroupMembershipUpdate>,
) -> Result<()> {
Ok(theseus::instance::set_screenshot_group_memberships(updates).await?)
}
#[tauri::command]
pub async fn instance_import_screenshot_groups(
groups: Vec<theseus::instance::ScreenshotGroupImport>,
) -> Result<()> {
Ok(theseus::instance::import_screenshot_groups(groups).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
) -> Result<()> {
Ok(theseus::instance::delete_screenshots(&keys).await?)
}
#[tauri::command]
pub async fn instance_export_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
path: PathBuf,
) -> Result<()> {
Ok(theseus::instance::export_screenshots(&keys, path).await?)
}
#[tauri::command]
pub async fn instance_move_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
target: &str,
) -> Result<Vec<theseus::instance::ScreenshotKey>> {
Ok(theseus::instance::move_screenshots(&keys, target).await?)
}
#[tauri::command]
pub async fn instance_open_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
) -> Result<()> {
let path = theseus::instance::get_screenshot_path(&key).await?;
app.opener()
.reveal_item_in_dir(path)
.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(())
}
+5 -18
View File
@@ -4,7 +4,7 @@ use std::path::PathBuf;
use theseus::logs::LogType; use theseus::logs::LogType;
use theseus::logs::{ use theseus::logs::{
self, CensoredString, CrashAnalysis, CrashAnalysisAiExplanation, self, CensoredString, CrashAnalysis, CrashAnalysisAiExplanation,
CrashAnalysisAiSettings, LatestLogCursor, Logs, CrashAnalysisAiSettings, LatestLogCursor, LiveLogSnapshot, Logs,
}; };
/* /*
@@ -26,8 +26,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
logs_delete_logs, logs_delete_logs,
logs_delete_logs_by_filename, logs_delete_logs_by_filename,
logs_get_latest_log_cursor, logs_get_latest_log_cursor,
logs_get_minecraft_latest_log_cursor, logs_get_live_log_snapshot,
logs_get_live_log_buffer,
logs_clear_live_log_buffer, logs_clear_live_log_buffer,
logs_analyze_crash, logs_analyze_crash,
logs_get_crash_analysis_ai_settings, logs_get_crash_analysis_ai_settings,
@@ -95,24 +94,12 @@ pub async fn logs_get_latest_log_cursor(
Ok(logs::get_latest_log_cursor(instance_id, cursor).await?) Ok(logs::get_latest_log_cursor(instance_id, cursor).await?)
} }
/// Get Minecraft's logs/latest.log from a cursor.
#[tauri::command]
pub async fn logs_get_minecraft_latest_log_cursor(
instance_id: &str,
cursor: u64, // 0 to start at beginning of file
) -> Result<LatestLogCursor> {
Ok(
logs::get_generic_live_log_cursor(instance_id, "latest.log", cursor)
.await?,
)
}
/// Get all buffered live log lines for an instance. /// Get all buffered live log lines for an instance.
#[tauri::command] #[tauri::command]
pub async fn logs_get_live_log_buffer( pub async fn logs_get_live_log_snapshot(
instance_id: &str, instance_id: &str,
) -> Result<CensoredString> { ) -> Result<LiveLogSnapshot> {
Ok(logs::get_live_log_buffer(instance_id).await?) Ok(logs::get_live_log_snapshot(instance_id).await?)
} }
/// Clear the live log buffer for an instance. /// Clear the live log buffer for an instance.
+14 -2
View File
@@ -1,5 +1,5 @@
use crate::api::Result; use crate::api::Result;
use tauri::{Emitter, Runtime}; use tauri::{Emitter, Manager, Runtime};
use theseus::prelude::*; use theseus::prelude::*;
use theseus::{ProxyConfig, ProxyTestResult}; use theseus::{ProxyConfig, ProxyTestResult};
@@ -12,7 +12,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
cancel_directory_change, cancel_directory_change,
proxy_get, proxy_get,
proxy_set, proxy_set,
proxy_test proxy_test,
set_ui_scale
]) ])
.build() .build()
} }
@@ -37,6 +38,17 @@ pub async fn settings_set(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn set_ui_scale(
app: tauri::AppHandle<impl Runtime>,
factor: f64,
) -> Result<()> {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_zoom(factor);
}
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn download_engine_set( pub async fn download_engine_set(
engine: settings::DownloadEngine, engine: settings::DownloadEngine,
+4 -1
View File
@@ -548,6 +548,10 @@ fn schedule_tray_menu_update(app: &AppHandle) {
}); });
} }
pub fn state_initialized(app: &AppHandle) {
schedule_tray_menu_update(app);
}
async fn rebuild_tray_menu( async fn rebuild_tray_menu(
app: &AppHandle, app: &AppHandle,
active: bool, active: bool,
@@ -708,7 +712,6 @@ pub fn init(app: &AppHandle) {
.build(app) .build(app)
.expect("failed to create system tray"); .expect("failed to create system tray");
let _tray = tray; let _tray = tray;
schedule_tray_menu_update(app);
let app_handle = app.clone(); let app_handle = app.clone();
app.listen("process", move |event| { app.listen("process", move |event| {
let Ok(payload) = let Ok(payload) =
+9 -2
View File
@@ -315,6 +315,8 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
app.fs_scope() app.fs_scope()
.allow_directory(state.directories.servers_dir(), true)?; .allow_directory(state.directories.servers_dir(), true)?;
lightweight_mode::state_initialized(&app);
Ok(()) Ok(())
} }
@@ -569,8 +571,8 @@ fn is_dev() -> bool {
#[tauri::command] #[tauri::command]
fn are_updates_enabled() -> bool { fn are_updates_enabled() -> bool {
// StarLight launcher enables the built-in updater; the endpoint and // The frontend waits for launcher state initialization before starting
// signing public key are configured in tauri-release.conf.json. // the built-in updater.
true true
} }
@@ -909,6 +911,11 @@ fn main() {
Ok(()) Ok(())
}); });
#[cfg(feature = "updater")]
{
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
}
builder = builder builder = builder
.plugin(api::ai::init()) .plugin(api::ai::init())
.plugin(api::auth::init()) .plugin(api::auth::init())
+7
View File
@@ -71,6 +71,13 @@
"schemes": ["starlight"] "schemes": ["starlight"]
}, },
"mobile": [] "mobile": []
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ0NDhERTUxN0E3MERBNDYKUldSRzJuQjZVZDVJMUpKSUJ6blVpVzJQSTgvSjlyeVRGK0ozRzcwVWtpbTJjbUl0V0NRcC9OQVoK",
"endpoints": ["https://skin.starlight.cool/starlight/launcher/latest"],
"windows": {
"installMode": "quiet"
}
} }
}, },
"app": { "app": {
+183 -1
View File
@@ -1,6 +1,188 @@
{ {
"updated_at": "2026-09-25T23:51:11.379Z", "updated_at": "2026-10-01T05:02:05.765Z",
"announcements": [ "announcements": [
{
"id": "launcher-1.0.1-beta6",
"version": "1.0.1-beta6",
"publishedAt": "2026-10-01",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta6",
"zh-CN": "Starlight Launcher 1.0.1-beta6"
},
"changes": {
"fixed": [
{
"en-US": "Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.",
"zh-CN": "修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。"
},
{
"en-US": "Fixed the onboarding tour target on the minimal home layout.",
"zh-CN": "修复精简主页下导览目标找不到的问题。"
}
],
"added": [
{
"en-US": "Feature flag names in developer settings are now shown in Chinese.",
"zh-CN": "开发者设置中的功能开关名现在显示中文。"
}
]
}
},
{
"id": "launcher-1.0.1-beta5",
"version": "1.0.1-beta5",
"publishedAt": "2026-10-01",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta5",
"zh-CN": "Starlight Launcher 1.0.1-beta5"
},
"changes": {
"added": [
{
"en-US": "Reworked the onboarding tour, covering the home page, StarLight login, announcements, instance creation, discovery, skins, library, lab, downloads, and update settings in order.",
"zh-CN": "重做了新手导览,依次介绍主页、StarLight 登录、公告、创建实例、发现内容、皮肤、实例库、实验室、下载与更新设置。"
}
],
"fixed": [
{
"en-US": "Fixed the developer feature flags page rendering blank.",
"zh-CN": "修复开发者“功能开关”页面空白的问题。"
},
{
"en-US": "Fixed a build error that prevented the launcher from compiling.",
"zh-CN": "修复导致启动器无法编译的构建错误。"
}
]
}
},
{
"id": "launcher-1.0.1-beta4",
"version": "1.0.1-beta4",
"publishedAt": "2026-09-30",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta4",
"zh-CN": "Starlight Launcher 1.0.1-beta4"
},
"changes": {
"fixed": [
{
"en-US": "Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.",
"zh-CN": "修复 1.0.1-beta3 中导致启动器无法编译的构建错误。"
}
]
}
},
{
"id": "launcher-1.0.1-beta3",
"version": "1.0.1-beta3",
"publishedAt": "2026-09-30",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta3",
"zh-CN": "Starlight Launcher 1.0.1-beta3"
},
"changes": {
"added": [
{
"en-US": "Added a launcher UI scale setting under Appearance, which also helps when embedded pages are cut off.",
"zh-CN": "在“界面与外观”中新增启动器缩放设置,同时可缓解内嵌网页被裁切的问题。"
},
{
"en-US": "Added a one-time welcome announcement on first launch, with an entry in the home sidebar.",
"zh-CN": "新增首次启动时的欢迎公告弹窗,并在主页侧栏提供入口。"
}
],
"fixed": [
{
"en-US": "Fixed the browse page going blank without a CurseForge API key; the navigation tabs now stay visible.",
"zh-CN": "修复未配置 CurseForge API Key 时浏览页整页空白的问题,导航栏现在保持可见。"
},
{
"en-US": "Fixed the about page license links and the update changelog link to point at the correct repository.",
"zh-CN": "修复关于页许可证链接与更新日志链接,改为指向正确的仓库。"
}
]
}
},
{
"id": "launcher-1.0.1-beta2",
"version": "1.0.1-beta2",
"publishedAt": "2026-09-28",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta2",
"zh-CN": "Starlight Launcher 1.0.1-beta2"
},
"changes": {
"added": [
{
"en-US": "GitHub releases now include one complete update package for publishing to the StarLight skin site.",
"zh-CN": "GitHub 发布现会生成一个可直接发布到 StarLight 皮肤站的完整更新包。"
}
],
"fixed": [
{
"en-US": "Improved the reliability of live logs and database switching.",
"zh-CN": "提高实时日志与数据库切换的可靠性。"
}
]
}
},
{
"id": "launcher-1.0.1-beta1",
"version": "1.0.1-beta1",
"publishedAt": "2026-09-27",
"title": {
"en-US": "Starlight Launcher 1.0.1-beta1",
"zh-CN": "Starlight Launcher 1.0.1-beta1"
},
"changes": {
"added": [
{
"en-US": "Added a one-time welcome announcement on first launch, covering the development team, the StarLight-only note, the rulebook update, and server connection addresses.",
"zh-CN": "新增首次启动时的欢迎公告弹窗,包含开发团队、StarLight 专用说明、服规更新与服务器连接地址。"
}
]
}
},
{
"id": "launcher-1.0.0",
"version": "1.0.0",
"publishedAt": "2026-09-26",
"title": {
"en-US": "Starlight Launcher 1.0.0",
"zh-CN": "Starlight Launcher 1.0.0"
},
"changes": {
"added": [
{
"en-US": "Enabled the built-in automatic updater. The launcher can now check for and install new versions from the update channel.",
"zh-CN": "启用内置自动更新,启动器现在可以从更新通道检查并安装新版本。"
}
],
"fixed": [
{
"en-US": "Fixed a crash on startup caused by the updater running before launcher state finished initializing.",
"zh-CN": "修复了更新器在启动器状态初始化完成前运行导致的启动崩溃。"
}
]
}
},
{
"id": "launcher-1.0.0-beta15",
"version": "1.0.0-beta15",
"publishedAt": "2026-09-26",
"title": {
"en-US": "Starlight Launcher 1.0.0-beta15",
"zh-CN": "Starlight Launcher 1.0.0-beta15"
},
"changes": {
"fixed": [
{
"en-US": "Fixed the launcher crashing on startup in 1.0.0-beta14 by temporarily reverting the built-in updater. The updater will be re-enabled after the crash is resolved.",
"zh-CN": "修复 1.0.0-beta14 中启动器一打开就崩溃的问题:暂时回退了内置自动更新。待崩溃问题解决后会重新启用。"
}
]
}
},
{ {
"id": "launcher-1.0.0-beta14", "id": "launcher-1.0.0-beta14",
"version": "1.0.0-beta14", "version": "1.0.0-beta14",
+29 -30
View File
@@ -1,34 +1,33 @@
{ {
"tag_name": "v1.9.5", "tag_name": "v1.0.0",
"assets": [ "assets": [
"Axolotl.Launcher-1.9.5-1.aarch64.rpm", "Axolotl_Launcher_1.0.0_x64_modern-setup.exe",
"Axolotl.Launcher-1.9.5-1.aarch64.rpm.sig", "Axolotl_Launcher_1.0.0_x64_modern-setup.exe.sig",
"Axolotl.Launcher-1.9.5-1.x86_64.rpm", "Axolotl_Launcher_1.0.0_x64_nsis-setup.exe",
"Axolotl.Launcher-1.9.5-1.x86_64.rpm.sig", "Axolotl_Launcher_1.0.0_x64_nsis-setup.exe.sig",
"Axolotl.Launcher_1.9.5_aarch64.AppImage", "Axolotl_Launcher_1.0.0_x64_portable.zip",
"Axolotl.Launcher_1.9.5_aarch64.AppImage.sig", "Starlight.Launcher-1.0.0-1.aarch64.rpm",
"Axolotl.Launcher_1.9.5_aarch64.AppImage.tar.gz", "Starlight.Launcher-1.0.0-1.aarch64.rpm.sig",
"Axolotl.Launcher_1.9.5_aarch64.AppImage.tar.gz.sig", "Starlight.Launcher-1.0.0-1.x86_64.rpm",
"Axolotl.Launcher_1.9.5_amd64.AppImage", "Starlight.Launcher-1.0.0-1.x86_64.rpm.sig",
"Axolotl.Launcher_1.9.5_amd64.AppImage.sig", "Starlight.Launcher_1.0.0_aarch64.AppImage",
"Axolotl.Launcher_1.9.5_amd64.AppImage.tar.gz", "Starlight.Launcher_1.0.0_aarch64.AppImage.sig",
"Axolotl.Launcher_1.9.5_amd64.AppImage.tar.gz.sig", "Starlight.Launcher_1.0.0_aarch64.AppImage.tar.gz",
"Axolotl.Launcher_1.9.5_amd64.deb", "Starlight.Launcher_1.0.0_aarch64.AppImage.tar.gz.sig",
"Axolotl.Launcher_1.9.5_amd64.deb.sig", "Starlight.Launcher_1.0.0_amd64.AppImage",
"Axolotl.Launcher_1.9.5_arm64.deb", "Starlight.Launcher_1.0.0_amd64.AppImage.sig",
"Axolotl.Launcher_1.9.5_arm64.deb.sig", "Starlight.Launcher_1.0.0_amd64.AppImage.tar.gz",
"Axolotl.Launcher_1.9.5_universal.dmg", "Starlight.Launcher_1.0.0_amd64.AppImage.tar.gz.sig",
"Axolotl.Launcher_1.9.5_x64-setup.exe", "Starlight.Launcher_1.0.0_amd64.deb",
"Axolotl.Launcher_1.9.5_x64-setup.exe.sig", "Starlight.Launcher_1.0.0_amd64.deb.sig",
"Axolotl.Launcher_1.9.5_x64-setup.nsis.zip", "Starlight.Launcher_1.0.0_arm64.deb",
"Axolotl.Launcher_1.9.5_x64-setup.nsis.zip.sig", "Starlight.Launcher_1.0.0_arm64.deb.sig",
"Axolotl.Launcher_universal.app.tar.gz", "Starlight.Launcher_1.0.0_universal.dmg",
"Axolotl.Launcher_universal.app.tar.gz.sig", "Starlight.Launcher_1.0.0_x64-setup.exe",
"Axolotl_Launcher_1.9.5_x64_modern-setup.exe", "Starlight.Launcher_1.0.0_x64-setup.exe.sig",
"Axolotl_Launcher_1.9.5_x64_modern-setup.exe.sig", "Starlight.Launcher_1.0.0_x64-setup.nsis.zip",
"Axolotl_Launcher_1.9.5_x64_nsis-setup.exe", "Starlight.Launcher_1.0.0_x64-setup.nsis.zip.sig",
"Axolotl_Launcher_1.9.5_x64_nsis-setup.exe.sig", "Starlight.Launcher_universal.app.tar.gz",
"Axolotl_Launcher_1.9.5_x64_portable.zip", "Starlight.Launcher_universal.app.tar.gz.sig"
"latest.json"
] ]
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshot_groups WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27"
}
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more