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
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (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
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
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (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
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 (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-01 16:21:30 +08:00
AxTps 81b7219085 feat: 恢复多人联机(陶瓦)侧边栏入口,挂于功能开关后
Axolotl desktop CI / 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 / typos (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
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 (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Release preflight (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 arm64) (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 / website (push) Has been cancelled
2026-10-01 13:02:39 +08:00
AxTps f839796585 feat: 功能开关页显示中文名 2026-10-01 12:57:48 +08:00
AxTps e25135934f fix: 导览气泡让开左侧导航栏
Axolotl desktop CI / 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 / typos (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
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 (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-01 11:40:23 +08:00
AxTps 6b1d2e1943 fix: 修复新手导览自动跳过、卡住与步骤不跳转
Axolotl desktop CI / 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 / typos (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
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
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Release preflight (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 arm64) (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 / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
- catalog.ts 增量新增 1.0.1-beta5 公告(重做导览、功能开关修复、
  编译错误修复);
- 网站 catalog.json 重新生成。
2026-10-01 09:35:05 +08:00
AxTps c7d1e44719 feat: 重做新手导览
按新的顺序重写 main 导览(10 个内容点,导航 + 高亮共 16 步):
主页、登录 StarLight、侧栏公告、创建 StarLight 实例、发现内容、
皮肤选择器、实例库、实验室、下载、设置的更新页。

- onboardingConfig.ts 重写 onboardingTours.main,并新增 home-intro/
  account-login/announcement/create-starlight/settings-updates 文案;
- HomeAnnouncementEntry.vue 增加 data-onboarding-id="home-announcement"
  锚点;
- en-US / zh-CN 补齐上述新增文案。
2026-10-01 09:21:39 +08:00
AxTps 0a6fa0eb35 chore(ci): tombi 改用 npx 安装
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
taiki-e/install-action@v2.81.9 不支持 tombi 1.6.1(报 "version
1.6.1 is not supported")。改用 npx 安装 tombi@1.6.1,避免
install-action 的版本白名单限制,并与本地格式化版本一致。
2026-10-01 08:20:46 +08:00
AxTps 79fdddc075 chore: 修复仓库检查(tombi 格式、typos 拼写)
Axolotl desktop CI / 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 / typos (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
- 移除意外提交的 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
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (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
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
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (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
Starlight release / Release preflight (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 arm64) (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
- 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 (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Release preflight (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 arm64) (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 / website (push) Has been cancelled
- catalog.ts 增量新增 1.0.1-beta3 公告(界面缩放、首次启动公告、
  浏览页与链接修复);
- 网站 catalog.json 重新生成。
2026-09-30 23:13:34 +08:00
AxTps 5bd785d780 chore(i18n): 补充界面缩放设置的中英文文案并清理多余键
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
- en-US / zh-CN 新增 app.appearance-settings.ui-scale.title 与
  .description;
- 移除未使用的 ui-scale.custom / ui-scale.percent 文案键。
2026-09-30 22:55:34 +08:00
AxTps 94cbcef324 feat: 新增启动器界面缩放设置
在高缩放/小屏下,内嵌网页(如皮肤站)顶栏会被裁掉。新增“界面
与外观”中的“启动器缩放”设置,可整体缩放启动器界面。

- 新增 helpers/ui-scale.ts:用 localStorage 保存缩放值(纯本地
  显示偏好,无需后端),并通过 CSS zoom 应用缩放(含内嵌网页)。
- App.vue 启动时调用 installUiScale() 应用已保存的缩放。
- AppearanceSettings.vue 新增缩放设置项:100/110/125/150 预设
  加 50–200 滑条自定义。
2026-09-30 22:40:24 +08:00
AxTps d8a6d44890 fix: 关于页许可证与更新日志链接指向 GitHub 仓库
Axolotl desktop CI / 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 / typos (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
- 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
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (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
此前在世界地图浏览(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
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (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
首次启动公告未弹出,原因有二:
- 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
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (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
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 / typos (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
Starlight release / Release preflight (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux arm64) (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
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
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (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
Sync LobeHub models / sync (push) Has been cancelled
2026-09-27 14:18:25 +08:00
147 changed files with 12372 additions and 26729 deletions

No files matched your search

+8 -8
View File
@@ -1,10 +1,3 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target."cfg(windows)"]
rustflags = ["-C", "link-args=/STACK:16777220"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
# Reproducible public-service defaults for downstream desktop builds. Private
# Modrinth services remain disabled by Axolotl capabilities at runtime.
[env]
@@ -12,5 +5,12 @@ MODRINTH_API_BASE_URL = "https://api.modrinth.com"
MODRINTH_API_URL = "https://api.modrinth.com/v2/"
MODRINTH_API_URL_V3 = "https://api.modrinth.com/v3/"
MODRINTH_LAUNCHER_META_URL = "https://launcher-meta.modrinth.com/"
MODRINTH_URL = "https://modrinth.com/"
MODRINTH_SOCKET_URL = "wss://disabled.invalid/"
MODRINTH_URL = "https://modrinth.com/"
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target."cfg(windows)"]
rustflags = ["-C", "link-args=/STACK:16777220"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
+23
View File
@@ -351,6 +351,29 @@ jobs:
release-check/update-server-catalog.json
node scripts/axolotl/create-update-server-catalog.test.mjs
- name: Build complete skin-site update package
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version="${GITHUB_REF_NAME#v}"
bundle_root="release-check/skin-site-update"
bundle_name="Starlight_Launcher_${version}_skin-site-update.zip"
mkdir -p "${bundle_root}/files"
gh release download "${GITHUB_REF_NAME}" --dir "${bundle_root}/files"
find "${bundle_root}/files" -maxdepth 1 -type f \
\( -name 'latest.json' -o -name '*_skin-site-update.zip' \) -delete
cp release-check/latest.json "${bundle_root}/latest.json"
cp release-check/update-server-catalog.json "${bundle_root}/update-server-catalog.json"
cp release-check/release.json "${bundle_root}/release.json"
(
cd "${bundle_root}"
zip -r -9 "../${bundle_name}" latest.json update-server-catalog.json release.json files
)
gh release upload "${GITHUB_REF_NAME}" "release-check/${bundle_name}" --clobber
- name: Notify Update Server to publish release assets
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
env:
+2 -5
View File
@@ -22,8 +22,5 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
with:
tool: tombi@1.1.2
- run: tombi lint
- run: tombi fmt --check
- run: npx --yes tombi@1.6.1 lint
- run: npx --yes tombi@1.6.1 fmt --check
+1
View File
@@ -38,6 +38,7 @@ node_modules
npm-debug.log
yarn-error.log
testem.log
replay_pid*.log
/typings
# System Files
Generated
+2 -2
View File
@@ -6795,7 +6795,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "1.0.0-beta12"
version = "1.0.0-beta15"
dependencies = [
"ariadne",
"async-minecraft-ping",
@@ -6899,7 +6899,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "1.0.0-beta12"
version = "1.0.0-beta15"
dependencies = [
"ashpd",
"async_zip",
+6 -24
View File
@@ -20,9 +20,7 @@ ariadne = { path = "packages/ariadne" }
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
async-recursion = "1.1.1"
async-trait = "0.1.89"
async-tungstenite = { version = "0.31.0", default-features = false, features = [
"futures-03-sink"
] }
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
async-walkdir = "2.1.0"
async_zip = "0.0.18"
base64 = "0.22.1"
@@ -32,9 +30,7 @@ bytes = "1.10.1"
cc = "1.2.56"
chardetng = "0.1.17"
chrono = "0.4.42"
cidre = { version = "0.15.0", default-features = false, features = [
"macos_15_0"
] }
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
crc32fast = "1.5.0"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
@@ -58,11 +54,7 @@ hmac = "0.12.1"
http = "1.3.1"
hyper = "1.7.0"
hyper-util = "0.1.17"
image = { version = "0.25.5", default-features = false, features = [
"jpeg",
"png",
"webp"
] }
image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "webp"] }
indicatif = "0.18.0"
itertools = "0.14.0"
jsonwebtoken = "9.3.1"
@@ -88,11 +80,7 @@ rand = "=0.8.5" # Locked on 0.8 until p256 updates to 0.9
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
rustls = { version = "0.23.42", default-features = false, features = [
"ring",
"std",
"tls12"
] }
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8.4"
rustls-pki-types = "1.15.0"
serde = "1.0.228"
@@ -127,10 +115,7 @@ tempfile = "3.23.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.17"
tokio = "1.47.1"
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring",
"tls12"
] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
tokio-util = "0.7.16"
toml = "0.8.19"
tracing = "0.1.41"
@@ -149,10 +134,7 @@ windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
winreg = "0.55.0"
xz2 = { version = "0.1.7", features = ["static"] }
zbus = "5.11.0"
zhconv = { version = "0.4.1", default-features = false, features = [
"compress",
"opencc"
] }
zhconv = { version = "0.4.1", default-features = false, features = ["compress", "opencc"] }
zip = { version = "6.0.0", default-features = false, features = [
"bzip2",
"deflate",
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.0-beta12",
"version": "1.0.0-beta15",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,6 +14,9 @@
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@dnd-kit/vue": "^0.5.0",
"@lobehub/icons-static-svg": "1.94.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
+67 -5
View File
@@ -8,6 +8,7 @@ import {
FlaskConicalIcon,
FolderOpenIcon,
HomeIcon,
ImagesIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
@@ -19,6 +20,7 @@ import {
SettingsIcon,
SpinnerIcon,
UserIcon,
UsersIcon,
WorldIcon,
} from '@modrinth/assets'
import {
@@ -63,7 +65,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import { type as getOsType } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { hideAllPoppers } from 'floating-vue'
import { computed, 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 InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
@@ -141,6 +143,7 @@ import {
isNetworkMetered,
setRestartAfterPendingUpdate,
} from '@/helpers/utils.js'
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n, { resolveInitialLocale } from '@/i18n.config'
import {
@@ -312,6 +315,22 @@ const showOnboarding = ref(false)
const onboardingMode = ref('main')
const onboardingSettings = ref(null)
const onboardingReplay = ref(false)
// Expand the sidebar while the onboarding tour is visible so steps that point
// at sidebar entries (account card, announcement, create instance) can find
// their targets. The previous state is restored when the tour ends.
let sidebarStateBeforeOnboarding: boolean | undefined
watch(showOnboarding, (visible) => {
if (visible) {
sidebarStateBeforeOnboarding = sidebarToggled.value
sidebarToggled.value = true
setSidebarExpanded(true)
} else if (sidebarStateBeforeOnboarding !== undefined) {
sidebarToggled.value = sidebarStateBeforeOnboarding
setSidebarExpanded(sidebarStateBeforeOnboarding)
sidebarStateBeforeOnboarding = undefined
}
})
const nativeDecorations = ref(false)
const os = ref('')
@@ -745,6 +764,10 @@ const messages = defineMessages({
defaultMessage: 'Library',
},
multiplayer: {
screenshots: {
id: 'app.navigation.screenshots',
defaultMessage: 'Screenshots',
},
id: 'app.navigation.multiplayer',
defaultMessage: 'Multiplayer',
},
@@ -1073,6 +1096,7 @@ async function setupApp() {
close_behavior,
developer_mode,
feature_flags,
ui_scale,
} = initialSettings
// Initialize locale from saved settings
@@ -1103,6 +1127,7 @@ async function setupApp() {
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
setUiScale(ui_scale)
themeStore.setThemeState(theme)
await initializeSystemAccentColor()
themeStore.setAccentColor(accent_color)
@@ -1271,6 +1296,9 @@ provide(
)
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
provide('showLauncherPopup', (_request: unknown) => {})
provide('showStartupAnnouncement', () => startupAnnouncementModal.value?.show())
installUiScale()
const stateFailed = ref(false)
stateInitialization
@@ -1544,13 +1572,27 @@ const startupAnnouncementModal = ref<InstanceType<typeof StartupAnnouncementModa
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 {
if (localStorage.getItem(STARTUP_ANNOUNCEMENT_KEY)) return
localStorage.setItem(STARTUP_ANNOUNCEMENT_KEY, '1')
} catch (error) {
console.warn('Failed to read/write startup announcement flag', error)
console.warn('Failed to write startup announcement flag', error)
}
startupAnnouncementModal.value?.show()
}
// ── Drop import system ──────────────────────────────────────────────────
@@ -2278,6 +2320,23 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<LibraryIcon />
</NavButton>
<NavButton
v-if="themeStore.featureFlags.multiplayer_in_app"
v-tooltip.right="formatMessage(messages.multiplayer)"
data-onboarding-id="nav-multiplayer"
to="/multiplayer"
:is-primary="(r) => r.path.startsWith('/multiplayer')"
>
<UsersIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.screenshots)"
data-onboarding-id="nav-screenshots"
to="/screenshots"
:is-primary="(r) => r.path.startsWith('/screenshots')"
>
<ImagesIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.lab)"
data-onboarding-id="nav-lab"
@@ -2491,7 +2550,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<div
data-onboarding-id="sidebar-accounts"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.userInformation) }}
</h3>
@@ -32,6 +32,145 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
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',
@@ -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>
@@ -201,7 +201,7 @@ onUnmounted(() => {
data-onboarding-id="home-instances"
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8"
>
<div class="flex w-full max-w-3xl flex-col items-center text-center">
<div data-onboarding-id="home-widget-grid" class="flex w-full max-w-3xl flex-col items-center text-center">
<HomeGreeting :player-name="playerName" variant="minimal" />
<template v-if="selectedInstance">
@@ -30,6 +30,8 @@ function hide() {
function openLink(url: string) {
void openUrl(url)
}
defineExpose({ show })
</script>
<template>
@@ -61,7 +61,7 @@ const historyAnnouncements = computed(() =>
<UpdateAnnouncementContent
:announcement="currentAnnouncement"
:version="currentVersion"
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.website"
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.releasesUrl"
/>
</div>
@@ -102,7 +102,7 @@ const historyAnnouncements = computed(() =>
<UpdateAnnouncementContent
:announcement="announcement"
:show-header="false"
:external-url="announcement.externalUrl"
:external-url="announcement.externalUrl ?? `${AxolotlBrandConfig.releasesUrl}/tag/v${announcement.version}`"
/>
</div>
</Accordion>
@@ -170,7 +170,7 @@ const {
top: auto !important;
right: 0;
bottom: 0;
left: 0 !important;
left: var(--left-bar-width, 4rem) !important;
z-index: 2;
width: auto;
min-height: 0;
@@ -276,6 +276,48 @@ export const onboardingMessages = defineMessages({
},
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
homeIntroTitle: { id: 'app.onboarding.home-intro.title', defaultMessage: 'Welcome home' },
homeIntroDescription: {
id: 'app.onboarding.home-intro.description',
defaultMessage:
'This is your home page. It shows your instances, recent activity, and quick shortcuts to get playing.',
},
accountLoginTitle: {
id: 'app.onboarding.account-login.title',
defaultMessage: 'Sign in to StarLight',
},
accountLoginDescription: {
id: 'app.onboarding.account-login.description',
defaultMessage:
'Sign in to the StarLight skin site here to use your StarLight players, install hosted modpacks, and sync your account.',
},
announcementTitle: {
id: 'app.onboarding.announcement.title',
defaultMessage: 'Latest announcements',
},
announcementDescription: {
id: 'app.onboarding.announcement.description',
defaultMessage:
'Open the announcement entry in the sidebar to read the latest news, rules, and server notices.',
},
createStarlightTitle: {
id: 'app.onboarding.create-starlight.title',
defaultMessage: 'Create a StarLight instance',
},
createStarlightDescription: {
id: 'app.onboarding.create-starlight.description',
defaultMessage:
'Use Create new instance here. A StarLight instance installs the server-selected modpack and versions, then keeps them updated before each launch.',
},
settingsUpdatesTitle: {
id: 'app.onboarding.settings-updates.title',
defaultMessage: 'Stay up to date',
},
settingsUpdatesDescription: {
id: 'app.onboarding.settings-updates.description',
defaultMessage:
'The Updates tab controls the update channel, checks for new launcher versions, and shows the changelog.',
},
})
const step = (
@@ -331,20 +373,33 @@ const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescri
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
main: [
step(
'welcome',
'manual',
copy(
onboardingMessages.welcomeTitle,
onboardingMessages.welcomeDescription,
onboardingMessages.start,
),
inspect(
'home-intro',
'home-widget-grid',
onboardingMessages.homeIntroTitle,
onboardingMessages.homeIntroDescription,
),
inspect(
'home-layout-switch',
'home-layout-switch',
onboardingMessages.homeLayoutTitle,
onboardingMessages.homeLayoutDescription,
'account-login',
'sidebar-accounts',
onboardingMessages.accountLoginTitle,
onboardingMessages.accountLoginDescription,
),
inspect(
'announcement',
'home-announcement',
onboardingMessages.announcementTitle,
onboardingMessages.announcementDescription,
),
step(
'create-starlight',
'navigate',
copy(
onboardingMessages.createStarlightTitle,
onboardingMessages.createStarlightDescription,
onboardingMessages.clickCreate,
),
control('create-instance', '/create'),
),
step(
'discover-navigation',
@@ -354,7 +409,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.discoverDescription,
onboardingMessages.clickDiscover,
),
control('nav-discover', '/browse/modpack'),
control('nav-discover', '/browse'),
),
inspect(
'discover-content',
@@ -362,22 +417,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.browseTitle,
onboardingMessages.browseDescription,
),
step(
'discover-favorites-navigation',
'navigate',
copy(
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
onboardingMessages.clickFavorites,
),
control('browse-favorites-tab', '/browse/favorites'),
),
inspect(
'discover-favorites-content',
'browse-favorites-content',
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
),
step(
'skins-navigation',
'navigate',
@@ -394,6 +433,22 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.skinsPageTitle,
onboardingMessages.skinsPageDescription,
),
step(
'library-navigation',
'navigate',
copy(
onboardingMessages.libraryTitle,
onboardingMessages.libraryDescription,
onboardingMessages.clickLibrary,
),
control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
),
step(
'lab-navigation',
'navigate',
@@ -428,7 +483,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
),
step(
'settings-navigation',
'activate',
'navigate',
copy(
onboardingMessages.settingsTitle,
onboardingMessages.settingsDescription,
@@ -436,95 +491,15 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
),
control('nav-settings', '/settings'),
),
...settingsTourSteps.map(([id, targetId, title, description], index) =>
step(id, 'activate', copy(title, description, onboardingMessages.clickTab), {
...control(targetId),
closeSettingsAfter: index === settingsTourSteps.length - 1,
}),
),
step(
'library-navigation',
'navigate',
copy(
onboardingMessages.libraryTitle,
onboardingMessages.libraryDescription,
onboardingMessages.clickLibrary,
),
control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
),
step(
'create-instance',
'navigate',
copy(
onboardingMessages.createTitle,
onboardingMessages.createDescription,
onboardingMessages.clickCreate,
),
control('create-instance', '/create'),
),
inspect(
'creation-instance-mode',
'creation-instance-mode',
onboardingMessages.instanceModeTitle,
onboardingMessages.instanceModeDescription,
),
step(
'creation-flow',
'settings-updates',
'activate',
copy(
onboardingMessages.creationTitle,
onboardingMessages.creationDescription,
onboardingMessages.clickCreationMethod,
onboardingMessages.settingsUpdatesTitle,
onboardingMessages.settingsUpdatesDescription,
onboardingMessages.clickTab,
),
{
targetId: 'creation-methods',
branchByTarget: {
'creation-method-starlight': { next: 'complete' },
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
'creation-method-import': { next: 'complete' },
},
},
),
inspect(
'creation-name',
'creation-name',
onboardingMessages.creationNameTitle,
onboardingMessages.creationNameDescription,
),
inspect(
'creation-loader',
'creation-loader',
onboardingMessages.creationLoaderTitle,
onboardingMessages.creationLoaderDescription,
),
step(
'creation-version',
'inspect',
copy(
onboardingMessages.creationVersionTitle,
onboardingMessages.creationVersionDescription,
onboardingMessages.continueArea,
),
{
targetId: 'creation-game-version',
nextByCreationPath: { custom: 'creation-confirm' },
},
),
step(
'creation-confirm',
'inspect',
copy(
onboardingMessages.creationConfirmTitle,
onboardingMessages.creationConfirmDescription,
onboardingMessages.finishArea,
),
{ targetId: 'creation-confirm' },
control('settings-tab-updates'),
),
],
instance: [
@@ -146,6 +146,14 @@ export function useOnboardingTour(
function scheduleMissingTargetRetry(stepId: string) {
if (targetRetryCount >= missingTargetRetryLimit) {
// 超过重试上限:inspect 步(介绍性步骤)不自动跳过,
// 保持无锚点状态,让气泡停靠底部等用户手动继续。
if (step.value.interaction === 'inspect') {
targetElement.value = undefined
targetRect.value = null
clearModalReservation()
return
}
void advance()
return
}
@@ -284,7 +292,7 @@ export function useOnboardingTour(
if (clickedElement && handleBranchClick(clickedElement)) return
if (!targetElement.value?.contains(event.target as Node)) return
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
if (step.value.expectedPath && !route.path.startsWith(step.value.expectedPath)) {
waitingForRoute.value = true
return
}
@@ -301,7 +309,7 @@ export function useOnboardingTour(
() => route.path,
(path) => {
if (!waitingForRoute.value || !step.value.expectedPath) return
if (path !== step.value.expectedPath) return
if (!path.startsWith(step.value.expectedPath)) return
waitingForRoute.value = false
void advance()
},
@@ -0,0 +1,289 @@
<script lang="ts"></script>
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
import {
defineMessages,
IconButton,
useDebugLogger,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
const props = defineProps<{
screenshot: InstanceScreenshot
selectionKey: string
selected: boolean
selectionActive: boolean
activeDragged: boolean
canDrag: boolean
showInstanceName: boolean
highlighted: boolean
copied: boolean
}>()
const emit = defineEmits<{
(e: 'activate', event: MouseEvent | KeyboardEvent): void
(e: 'copy' | 'edit' | 'toggle-selection'): void
(e: 'more', event: MouseEvent): void
}>()
const card = ref<HTMLElement>()
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const image = ref<HTMLImageElement>()
const imageReady = ref(false)
const { formatMessage } = useVIntl()
const debugImage = useDebugLogger('Screenshots:Card')
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
let loadStartedAt = performance.now()
let loadGeneration = 0
const messages = defineMessages({
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
})
const sensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
useDraggable({
id: computed(() => `screenshot:${props.selectionKey}`),
element: card,
disabled: computed(() => !props.canDrag),
sensors,
data: computed(() => ({
selectionKey: props.selectionKey,
instanceId: props.screenshot.instance_id,
})),
})
function activate(event: MouseEvent | KeyboardEvent) {
if (event instanceof KeyboardEvent) {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
return
}
event.preventDefault()
}
emit('activate', event)
}
async function markImageLoaded() {
const loadedImage = image.value
const loadedUrl = thumbnail.value
const generation = loadGeneration
if (!loadedImage) return
try {
await loadedImage.decode()
} catch {
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
}
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
})
if (
generation !== loadGeneration ||
image.value !== loadedImage ||
thumbnail.value !== loadedUrl
) {
return
}
const wasReady = imageReady.value
imageReady.value = true
if (!wasReady) {
debugImage('image loaded', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
naturalWidth: image.value?.naturalWidth,
naturalHeight: image.value?.naturalHeight,
})
}
}
function markImageFailed(event: Event) {
debugImage('image failed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
event,
})
}
onMounted(() => {
debugImage('mounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
complete: image.value?.complete,
})
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
})
onBeforeUnmount(() => {
loadGeneration += 1
debugImage('unmounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loaded: imageReady.value,
})
})
watch(thumbnail, (url, previousUrl) => {
loadGeneration += 1
loadStartedAt = performance.now()
imageReady.value = false
debugImage('source changed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
previousUrl,
url,
})
})
</script>
<template>
<article
ref="card"
role="button"
tabindex="0"
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-brand brightness-110': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
'opacity-50': activeDragged,
'cursor-grab active:cursor-grabbing': canDrag,
}"
data-screenshot-card
:data-screenshot-id="screenshot.id"
:data-selection-key="selectionKey"
:aria-label="
selectionActive
? formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
: screenshot.file_name
"
:aria-pressed="selectionActive ? selected : undefined"
@click="activate"
@contextmenu.prevent.stop="emit('more', $event)"
@keydown="activate"
>
<button
type="button"
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
:aria-label="
formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
"
:aria-pressed="selected"
@click.stop="emit('toggle-selection')"
>
<span
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
:class="
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-brand" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
<img
v-if="thumbnail"
ref="image"
:src="thumbnail"
:alt="screenshot.file_name"
loading="eager"
decoding="async"
draggable="false"
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
:class="imageReady ? 'opacity-100' : 'opacity-0'"
@load="markImageLoaded"
@error="markImageFailed"
/>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-surface-4"
:class="{ 'animate-pulse': !imageReady }"
/>
<div
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-[var(--color-text-primary)] opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
>
<div class="min-w-0">
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
{{ screenshot.file_name }}
</div>
<div class="truncate text-xs text-[var(--color-text-tertiary)]">
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
</div>
</div>
<div
v-if="!selectionActive"
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
@click.stop
>
<IconButton
v-tooltip="formatMessage(messages.edit)"
:label="formatMessage(messages.edit)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('edit')"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
:label="formatMessage(copied ? messages.copied : messages.copy)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('copy')"
>
<CheckIcon v-if="copied" class="text-green" />
<ClipboardCopyIcon v-else />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moreActions)"
:label="formatMessage(messages.moreActions)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('more', $event)"
>
<MoreHorizontalIcon />
</IconButton>
</div>
</div>
</article>
</template>
<style scoped>
.screenshot-card-fade {
transition: opacity 350ms ease-in-out;
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ScreenshotDragPreview from './drag-preview.vue'
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
const props = defineProps<{
items: ScreenshotDragGatherItem[]
target: {
x: number
y: number
}
}>()
const emit = defineEmits<{
complete: []
}>()
const gathered = ref(false)
const finishing = ref(false)
let animationFrame: number | undefined
let completionTimer: number | undefined
const itemContainerStyles = computed(() =>
props.items.map((_, index) => ({
left: `${props.target.x}px`,
top: `${props.target.y}px`,
zIndex: `${index}`,
})),
)
const itemStyles = computed(() =>
props.items.map((item, index) => {
const originX = item.rect.left - props.target.x
const originY = item.rect.top - props.target.y
return {
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transform: gathered.value
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
: `translate3d(${originX}px, ${originY}px, 0)`,
transitionDuration: `${gatherDuration}ms`,
transitionDelay: `${Math.min(index, 8) * 10}ms`,
}
}),
)
onMounted(() => {
animationFrame = requestAnimationFrame(() => {
animationFrame = requestAnimationFrame(() => {
gathered.value = true
completionTimer = window.setTimeout(
() => {
finishing.value = true
emit('complete')
},
Math.max(gatherDuration - 250, 0),
)
})
})
})
onBeforeUnmount(() => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
if (completionTimer !== undefined) {
clearTimeout(completionTimer)
}
})
</script>
<template>
<Teleport to="body">
<div
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
:class="finishing ? 'opacity-0' : 'opacity-100'"
aria-hidden="true"
>
<div
v-for="(item, index) in items"
:key="item.selectionKey"
class="fixed"
:style="itemContainerStyles[index]"
>
<div
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
:style="itemStyles[index]"
>
<ScreenshotDragPreview :screenshot="item.screenshot" />
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { TagItem } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
import { gatherDuration } from './use-screenshot-drag-gather'
const props = withDefaults(
defineProps<{
screenshot: InstanceScreenshot
count?: number
}>(),
{
count: 1,
},
)
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const showGatheredCount = ref(false)
let countTimer: number | undefined
onMounted(() => {
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showGatheredCount.value = true
return
}
countTimer = window.setTimeout(() => {
showGatheredCount.value = true
countTimer = undefined
}, gatherDuration - 150)
})
onBeforeUnmount(() => {
if (countTimer !== undefined) {
clearTimeout(countTimer)
}
})
</script>
<template>
<div aria-hidden="true" class="relative w-full select-none">
<div
v-if="count > 1"
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
/>
<div
v-if="count > 1"
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
/>
<div
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
>
<img v-if="thumbnail" :src="thumbnail" alt="" class="h-full w-full object-cover" />
<TagItem
v-if="count > 1"
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-[var(--color-text-tertiary)] motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
>
{{ count }}
</TagItem>
</div>
</div>
</template>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
import ScreenshotCard from './card.vue'
import ScreenshotSection from './section.vue'
const props = defineProps<{
id: string
title: string
screenshots: InstanceScreenshot[]
renderedScreenshots?: InstanceScreenshot[]
virtualGridHeight?: number
virtualGridTop?: number
selectedKeys: ReadonlySet<string>
selectionActive: boolean
activeDraggedKeys: ReadonlySet<string>
showDropOutline: boolean
canDrag: boolean
dropInstanceId?: string
dropCustomGroup?: boolean
dropCustomGroupId?: string
showInstanceName: boolean
highlightedScreenshotId?: string
copiedScreenshotIds: ReadonlySet<string>
forceOpen: boolean
hideHeader?: boolean
editableTitle?: boolean
startEditingTitle?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>()
const collapsed = defineModel<boolean>('collapsed', { required: true })
const dropTarget = ref<HTMLElement>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Group')
const messages = defineMessages({
emptyGroup: {
id: 'app.screenshots.group.empty',
defaultMessage: 'Drag and drop to add screenshots.',
},
})
const shouldShowGrid = computed(() =>
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
)
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
const renderGrid = ref(shouldShowGrid.value)
const virtualGridStyle = computed(() =>
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
)
const visibleGridStyle = computed(() =>
props.virtualGridTop === undefined
? undefined
: { transform: `translateY(${props.virtualGridTop}px)` },
)
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
let resizeObserver: ResizeObserver | undefined
async function logGeometry(reason: string) {
await nextTick()
const rect = dropTarget.value?.getBoundingClientRect()
debugLayout(reason, {
id: props.id,
title: props.title,
collapsed: collapsed.value,
shouldShowGrid: shouldShowGrid.value,
renderGrid: renderGrid.value,
screenshotCount: props.screenshots.length,
renderedCount: visibleScreenshots.value.length,
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
virtualGridHeight: props.virtualGridHeight,
virtualGridTop: props.virtualGridTop,
actual: rect
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
: undefined,
})
}
watch(
() => props.renderedScreenshots ?? props.screenshots,
(screenshots) => {
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
void logGeometry('rendered screenshots changed')
},
)
watch(
() => [props.virtualGridHeight, props.virtualGridTop] as const,
() => void logGeometry('virtual grid geometry changed'),
)
watch(
shouldShowGrid,
(showGrid, previouslyShown) => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
if (showGrid) {
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
renderGrid.value = true
void logGeometry('grid shown')
return
}
if (!previouslyShown) {
renderGrid.value = false
return
}
unmountGridTimeout = setTimeout(() => {
renderGrid.value = false
void logGeometry('grid unmounted after collapse')
unmountGridTimeout = undefined
}, 300)
},
{ flush: 'post' },
)
onMounted(() => {
debugLayout('mounted', { id: props.id, title: props.title })
void logGeometry('mounted geometry')
if (dropTarget.value) {
resizeObserver = new ResizeObserver(([entry]) => {
const rect = entry?.target.getBoundingClientRect()
debugLayout('actual size changed', {
id: props.id,
title: props.title,
width: rect?.width,
height: rect?.height,
expectedHeight:
(props.hideHeader ? 0 : 40) +
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
12,
})
})
resizeObserver.observe(dropTarget.value)
}
})
onBeforeUnmount(() => {
debugLayout('unmounted', {
id: props.id,
title: props.title,
renderedCount: visibleScreenshots.value.length,
})
resizeObserver?.disconnect()
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
})
const emit = defineEmits<{
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
}>()
useDroppable({
id: computed(() => `screenshot-group:${props.id}`),
element: dropTarget,
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
data: computed(() =>
props.dropCustomGroup
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
: { groupId: props.id, instanceId: props.dropInstanceId },
),
})
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
</script>
<template>
<div
ref="dropTarget"
class="group/instance-container relative select-none pb-3 transition-colors"
>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="!opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="!opacity-0"
>
<div
v-if="showDropOutline"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-brand bg-brand-highlight opacity-40"
/>
</Transition>
<ScreenshotSection
v-model:collapsed="collapsed"
:title="title"
:count="screenshots.length"
:force-open="forceOpen"
:hide-header="hideHeader"
:editable="editableTitle"
:start-editing="startEditingTitle"
:max-title-length="maxTitleLength"
:validate-title="validateTitle"
:on-title-change="onTitleChange"
>
<template #actions="{ startEditing }">
<slot name="actions" :start-editing="startEditing" />
</template>
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
<div
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
:style="visibleGridStyle"
>
<ScreenshotCard
v-for="screenshot in visibleScreenshots"
:key="getSelectionKey(screenshot)"
:screenshot="screenshot"
:selection-key="getSelectionKey(screenshot)"
:selected="selectedKeys.has(getSelectionKey(screenshot))"
:selection-active="selectionActive"
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
:can-drag="canDrag"
:show-instance-name="showInstanceName"
:highlighted="highlightedScreenshotId === screenshot.id"
:copied="copiedScreenshotIds.has(screenshot.id)"
@activate="(event) => emit('activate', screenshot, event)"
@toggle-selection="emit('toggle-selection', screenshot)"
@copy="emit('copy', screenshot)"
@edit="emit('edit', screenshot)"
@more="(event) => emit('more', screenshot, event)"
/>
<p
v-if="screenshots.length === 0"
key="empty-group"
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</div>
</div>
</ScreenshotSection>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import {
Accordion,
commonMessages,
defineMessages,
InlineEditableText,
TagItem,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
title: string
count: number
collapsed?: boolean
forceOpen?: boolean
hideHeader?: boolean
editable?: boolean
startEditing?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>(),
{
collapsed: false,
forceOpen: false,
hideHeader: false,
editable: false,
startEditing: false,
},
)
const emit = defineEmits<{
(e: 'update:collapsed', collapsed: boolean): void
}>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Section')
const accordion = ref<InstanceType<typeof Accordion>>()
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
const titleModel = ref(props.title)
const messages = defineMessages({
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
})
function toggle() {
debugLayout('toggle requested', {
title: props.title,
count: props.count,
isOpen: accordion.value?.isOpen,
})
if (accordion.value?.isOpen) {
accordion.value.close()
} else {
accordion.value?.open()
}
}
function handleOpen() {
debugLayout('opened', { title: props.title, count: props.count })
emit('update:collapsed', false)
}
function handleClose() {
debugLayout('closed', { title: props.title, count: props.count })
emit('update:collapsed', true)
}
onMounted(() => {
debugLayout('mounted', {
title: props.title,
count: props.count,
collapsed: props.collapsed,
hideHeader: props.hideHeader,
forceOpen: props.forceOpen,
})
})
onBeforeUnmount(() => {
debugLayout('unmounted', { title: props.title, count: props.count })
})
async function startTitleEditing() {
if (!props.editable) return
await titleInput.value?.startEditing()
}
watch(
() => props.title,
(title) => {
titleModel.value = title
},
)
watch(
() => props.startEditing,
async (shouldStartEditing) => {
if (!shouldStartEditing) return
await nextTick()
await startTitleEditing()
},
{ immediate: true, flush: 'post' },
)
</script>
<template>
<section class="flex w-full flex-col">
<div
v-if="!hideHeader"
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
>
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
<button
type="button"
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
:aria-expanded="accordion?.isOpen"
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
@click.stop="toggle"
>
<DropdownIcon
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-all duration-300 group-hover/open-target:text-[var(--color-text-default)]"
:class="{ 'rotate-180': accordion?.isOpen }"
/>
</button>
<InlineEditableText
v-if="editable"
ref="titleInput"
v-model="titleModel"
activation-mode="manual"
class="!h-10 select-none text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
:edit-label="formatMessage(commonMessages.renameButton)"
max-width="24rem"
icon-text-class="select-none"
:max-length="maxTitleLength"
:validate="validateTitle"
:on-change="onTitleChange"
/>
<span
v-else
class="select-none truncate text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
>
{{ title }}
</span>
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
{{ count }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<slot name="actions" :start-editing="startTitleEditing" />
</div>
<Accordion
ref="accordion"
:open-by-default="hideHeader || !props.collapsed"
:force-open="forceOpen"
overflow-visible
class="w-full"
@on-open="handleOpen"
@on-close="handleClose"
>
<div class="mt-2.5">
<slot />
</div>
</Accordion>
</section>
</template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ArrowUpDownIcon, GridIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Button,
Combobox,
type ComboboxOption,
defineMessages,
StyledInput,
useVIntl,
} from '@modrinth/ui'
const search = defineModel<string>('search', { required: true })
const sort = defineModel<string>('sort', { required: true })
const group = defineModel<string>('group', { required: true })
defineProps<{
sortOptions: ComboboxOption<string>[]
groupOptions: ComboboxOption<string>[]
}>()
const emit = defineEmits<{
(e: 'new-group'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
})
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="min-w-[16rem] flex-1"
/>
<Button @click="emit('new-group')">
<PlusIcon />
{{ formatMessage(messages.newGroup) }}
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<Combobox
v-model="sort"
class="w-max"
:options="sortOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<ArrowUpDownIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.sortBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
<Combobox
v-model="group"
class="w-max"
:options="groupOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<GridIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.groupBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
</div>
</div>
</template>
@@ -0,0 +1,130 @@
import { onBeforeUnmount, type Ref, ref } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
type Point = {
x: number
y: number
}
export type ActiveScreenshotDrag = {
primarySelectionKey: string
selectionKeys: string[]
}
export const gatherDuration = 500
export type ScreenshotDragGatherItem = {
screenshot: InstanceScreenshot
selectionKey: string
rect: {
left: number
top: number
width: number
height: number
}
}
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
export function useScreenshotDragGather(screenshots: Ref<InstanceScreenshot[]>) {
const items = ref<ScreenshotDragGatherItem[]>([])
const target = ref<Point>({ x: 0, y: 0 })
const targetOffset = ref<Point>({ x: 0, y: 0 })
const isGathering = ref(false)
let cleanupTimer: number | undefined
const clearCleanupTimer = () => {
if (cleanupTimer !== undefined) {
clearTimeout(cleanupTimer)
cleanupTimer = undefined
}
}
const clear = () => {
clearCleanupTimer()
items.value = []
targetOffset.value = { x: 0, y: 0 }
isGathering.value = false
}
const updateTarget = (pointer: Point) => {
target.value = {
x: pointer.x + targetOffset.value.x,
y: pointer.y + targetOffset.value.y,
}
}
const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => {
clear()
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return
const screenshotCards = Array.from(
document.querySelectorAll<HTMLElement>('[data-screenshot-card]'),
)
const sourceCard = screenshotCards.find(
(card) => card.dataset.selectionKey === drag.primarySelectionKey,
)
if (sourceCard) {
const sourceRect = sourceCard.getBoundingClientRect()
targetOffset.value = {
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
}
}
const screenshotsByKey = new Map(
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
)
items.value = drag.selectionKeys.flatMap((selectionKey) => {
const screenshot = screenshotsByKey.get(selectionKey)
const card = screenshotCards.find(
(candidate) =>
candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0,
)
if (!screenshot || !card) return []
const rect = card.getBoundingClientRect()
return [
{
screenshot,
selectionKey,
rect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
},
]
})
updateTarget(pointer)
isGathering.value = items.value.length > 0
}
const finish = () => {
isGathering.value = false
clearCleanupTimer()
cleanupTimer = window.setTimeout(() => {
items.value = []
cleanupTimer = undefined
}, 400)
}
onBeforeUnmount(clearCleanupTimer)
return {
items,
target,
isGathering,
start,
updateTarget,
clear,
finish,
}
}
@@ -26,6 +26,7 @@ import { computed, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { setUiScale, uiScale } from '@/helpers/ui-scale'
import { useTheming } from '@/store/state'
import {
type AccentColor,
@@ -209,6 +210,15 @@ const messages = defineMessages({
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
uiScaleTitle: {
id: 'app.appearance-settings.ui-scale.title',
defaultMessage: 'Launcher scale',
},
uiScaleDescription: {
id: 'app.appearance-settings.ui-scale.description',
defaultMessage:
'Scale the whole launcher interface. A smaller scale fits more content, which helps when embedded pages are cut off.',
},
pageTransitionsTitle: {
id: 'app.appearance-settings.page-transitions.title',
defaultMessage: 'Page transition animations',
@@ -363,6 +373,12 @@ const customBackgroundPreview = computed(() =>
: 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<{
value: AccentColor
color: string
@@ -1020,6 +1036,43 @@ watch(
/>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-ui-scale" tabindex="-1">
{{ formatMessage(messages.uiScaleTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.uiScaleDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex flex-wrap gap-2">
<button
v-for="preset in [100, 110, 125, 150]"
:key="preset"
type="button"
class="min-w-16 rounded-lg border border-solid px-3 py-1.5 text-sm font-semibold transition-colors"
:class="
uiScale === preset
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
"
@click="updateUiScale(preset)"
>
{{ preset }}%
</button>
</div>
<Slider
id="ui-scale"
:model-value="uiScale"
:min="50"
:max="200"
:step="5"
unit="%"
@update:model-value="(value) => updateUiScale(Number(value))"
/>
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-page-transitions" tabindex="-1">
@@ -7,7 +7,7 @@ import {
Toggle,
useVIntl,
} from '@modrinth/ui'
import { inject, watch } from 'vue'
import { inject, ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { isDev } from '@/helpers/utils'
@@ -54,6 +54,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 options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
@@ -85,7 +104,7 @@ watch(
<template>
<SettingsSection>
<SettingsRow v-for="option in options" :key="option">
<template #label>{{ option.replaceAll('_', ' ') }}</template>
<template #label>{{ featureFlagLabels[option] ?? option }}</template>
<template #control>
<div class="flex items-center gap-2">
<Button
@@ -124,4 +143,4 @@ watch(
</Button>
</div>
</SettingsSection>
</template>
</template>
@@ -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 = []
},
}
}
@@ -3,6 +3,7 @@ import test from 'node:test'
import {
clearHostedSession,
HOSTED_ACCOUNT_REQUIRED_MESSAGE,
hostedCreate,
hostedDefault,
hostedSync,
@@ -46,6 +47,10 @@ test('hosted installation sends the JWT to native commands while Local needs no
let createdId = 'instance'
let instanceExists = false
let tokenRequests = 0
let accounts: Array<{
account_type: string
yggdrasil?: { api_root: string }
}> = [{ account_type: 'microsoft' }]
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type !== 'starlight-pack-token-request') return
@@ -70,6 +75,14 @@ test('hosted installation sends the JWT to native commands while Local needs no
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
calls.push({ command, args })
if (command === 'plugin:install|hosted_has_game_account')
return accounts.some(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
'https://skin.starlight.cool/yggdrasil'),
)
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
if (command === 'plugin:instance|instance_get')
return instanceExists ? { id: args.instanceId } : null
@@ -99,7 +112,13 @@ test('hosted installation sends the JWT to native commands while Local needs no
await setInstanceMode('instance', 'starlight')
assert.equal(tokenRequests, 4)
assert.deepEqual(
calls.map(({ command }) => command),
calls
.map(({ command }) => command)
.filter(
(command) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
),
[
'hosted_set_session',
'hosted_default',
@@ -210,9 +229,35 @@ test('hosted installation sends the JWT to native commands while Local needs no
assert.equal(tokenRequests, requestsBeforeLogout)
const attempts: string[] = []
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
accounts = []
const installCallsBeforeAccountFailure = calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length
await assert.rejects(hostedSync('failed-instance'), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
await assert.rejects(hostedCreate(), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
assert.deepEqual(attempts, [])
assert.equal(
calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length,
installCallsBeforeAccountFailure,
)
const unauthenticatedCreation = useHostedCreation()
await unauthenticatedCreation.install()
assert.equal(unauthenticatedCreation.installError.value, HOSTED_ACCOUNT_REQUIRED_MESSAGE)
assert.equal(unauthenticatedCreation.createdInstance.value, undefined)
accounts = [{ account_type: 'microsoft' }]
const unauthenticatedRetry = hostedSync('failed-instance')
assert.deepEqual(attempts, ['failed-instance'])
await assert.rejects(unauthenticatedRetry)
assert.deepEqual(attempts, ['failed-instance'])
stopListening()
await assert.rejects(hostedSync('failed-instance'))
assert.equal(attempts.length, 1)
@@ -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
}
@@ -10,6 +10,10 @@ const createdInstance = ref<string>()
const completed = ref(false)
let generation = 0
function errorMessage(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause)
}
export function forgetHostedCreation(instanceId: string) {
if (createdInstance.value !== instanceId) return
generation++
@@ -27,7 +31,7 @@ export function markHostedCreationCompleted(instanceId: string) {
export function markHostedCreationFailed(instanceId: string, cause: unknown) {
if (createdInstance.value !== instanceId) return
completed.value = false
installError.value = String(cause)
installError.value = errorMessage(cause)
}
export function useHostedCreation() {
@@ -63,7 +67,7 @@ export function useHostedCreation() {
} catch (cause) {
if (attempt === generation) {
if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause)
else installError.value = String(cause)
else installError.value = errorMessage(cause)
}
} finally {
installing.value = false
@@ -2,12 +2,14 @@ import { createConsoleState } from '@modrinth/ui'
import {
clear_log_buffer,
get_live_log_buffer,
get_live_log_snapshot,
get_logs,
get_minecraft_latest_log_cursor,
} from '@/helpers/logs'
import { createLatestLogReader } from './latest-log-reader'
import {
createLiveLogReconciler,
type SequencedLogEvent,
} from './live-log-reconciler'
type ConsoleState = ReturnType<typeof createConsoleState>
@@ -25,8 +27,17 @@ interface InstanceConsoleEntry {
historicalConsole: ConsoleState
historicalCache: Map<string, string>
logList: LogEntry[] | null
liveHistoryHydration: Promise<void> | null
latestLog: ReturnType<typeof createLatestLogReader>
liveLog: ReturnType<typeof createLiveLogReconciler<ProcessLogPayload>>
}
interface ProcessLogPayload extends SequencedLogEvent {
type: 'log4j' | 'legacy'
message?: string
logger_name?: string
level?: string
thread_name?: string
timestamp_millis?: number
throwable?: string
}
const instances = new Map<string, InstanceConsoleEntry>()
@@ -36,19 +47,28 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
if (entry) return entry
const liveConsole = createConsoleState()
const liveLog = createLiveLogReconciler<ProcessLogPayload>(
() => get_live_log_snapshot(instanceId),
(snapshot) => {
liveConsole.clear()
void liveConsole.addLegacyLog(
snapshot.lines.map((line) => line.message).join('\n'),
)
},
(payload) => {
if (payload.type === 'log4j') {
liveConsole.addLog4jEvent(payload)
} else if (payload.message !== undefined) {
void liveConsole.addLegacyLog(payload.message)
}
},
)
entry = {
liveConsole,
historicalConsole: createConsoleState(),
historicalCache: new Map(),
logList: null,
liveHistoryHydration: null,
latestLog: createLatestLogReader(
(cursor) => get_minecraft_latest_log_cursor(instanceId, cursor),
(output, replace) => {
if (replace) liveConsole.clear()
void liveConsole.addLegacyLog(output)
},
),
liveLog,
}
instances.set(instanceId, entry)
return entry
@@ -56,28 +76,7 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
async function hydrate(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
if (entry.liveHistoryHydration) {
return entry.liveHistoryHydration
}
const hydration = (async () => {
await entry.latestLog.refresh().catch(console.warn)
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
const buffer = await get_live_log_buffer(instanceId)
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
await entry.liveConsole.addLegacyLog(buffer)
}
}
})()
entry.liveHistoryHydration = hydration
try {
await hydration
} finally {
if (entry.liveHistoryHydration === hydration) {
entry.liveHistoryHydration = null
}
}
await entry.liveLog.sync()
}
async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> {
@@ -109,15 +108,13 @@ function invalidate(instanceId: string): void {
async function clearLive(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
entry.latestLog.clear()
entry.liveConsole.clear()
await clear_log_buffer(instanceId).catch(() => {})
await clear_log_buffer(instanceId)
await entry.liveLog.resync()
}
async function destroy(instanceId: string): Promise<void> {
instances.get(instanceId)?.latestLog.reset()
function destroy(instanceId: string): void {
instances.get(instanceId)?.liveLog.dispose()
instances.delete(instanceId)
await clear_log_buffer(instanceId).catch(() => {})
}
export function useInstanceConsole(instanceId: string) {
@@ -130,13 +127,8 @@ export function useInstanceConsole(instanceId: string) {
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
invalidate: () => invalidate(instanceId),
clearLive: () => clearLive(instanceId),
refreshLive: () => entry.latestLog.refresh(),
flushLive: () => entry.latestLog.flush(),
acceptsProcessLogs: () => !entry.latestLog.active,
resetLive: () => {
entry.latestLog.reset()
entry.liveConsole.clear()
},
appendLive: (payload: ProcessLogPayload) => entry.liveLog.accept(payload),
resyncLive: () => entry.liveLog.resync(),
destroy: () => destroy(instanceId),
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ export const AxolotlBrandConfig = Object.freeze({
productName: 'Starlight Launcher',
shortProductName: 'Starlight',
website: 'https://skin.starlight.cool/',
repositoryUrl: 'https://git.starlight.cool/Disy/StarLight-Yggdrasil-Server',
repositoryUrl: 'https://github.com/ApTx2012/Starlight_Lancher',
releasesUrl: 'https://github.com/ApTx2012/Starlight_Lancher/releases',
supportUrl: 'https://skin.starlight.cool/',
qqGroupNumber: '000000000',
qqChannelUrl: 'https://skin.starlight.cool/',
+15 -3
View File
@@ -7,6 +7,8 @@ import {
waitForSkinSiteSession,
} from '../composables/skin-site-session.ts'
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
let sessionUpdate: Promise<void> = Promise.resolve()
const attemptListeners = new Set<(instanceId: string) => void>()
@@ -21,6 +23,13 @@ function startHostedPackAttempt(instanceId: string) {
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> {
const update = sessionUpdate
.catch(() => {})
@@ -100,13 +109,16 @@ export interface HostedSyncResult {
}
export const hostedDefault = () =>
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
export const hostedCreate = (gameDirRoot?: string | null) =>
invokeWithSession<string>('plugin:install|hosted_create', {
export const hostedCreate = async (gameDirRoot?: string | null) => {
await requireHostedGameAccount()
return invokeWithSession<string>('plugin:install|hosted_create', {
gameDirRoot: gameDirRoot ?? null,
})
}
export const hostedBinding = (instanceId: string) =>
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const hostedSync = (instanceId: string) => {
export const hostedSync = async (instanceId: string) => {
await requireHostedGameAccount()
startHostedPackAttempt(instanceId)
return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId })
}
@@ -0,0 +1 @@
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
+88 -1
View File
@@ -9,7 +9,7 @@ import {
type ContentItem,
type ContentOwner,
} from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { isOfflineMode } from '@/composables/useNetworkStatus'
import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector'
@@ -1124,3 +1124,90 @@ export async function check_symlink_capability(): Promise<SymlinkCapability> {
export async function allow_symlink_target(path: string): Promise<void> {
return await invoke('allow_symlink_target', { path })
}
export type ScreenshotKey = { instance_id: string; file_name: string }
export type InstanceScreenshot = {
id: string
instance_id: string
instance_name: string
file_name: string
created_at: string
modified_at: number
group_id: string | null
path: string
url: string
}
export type ScreenshotGroup = { id: string; name: string }
export type ScreenshotGroupImport = ScreenshotGroup & { screenshot_ids: string[] }
export type ScreenshotGroupMembershipUpdate = { screenshot_id: string; group_id: string | null }
export async function list_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
}
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_all_screenshots')
}
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_synced_screenshots')
}
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
return await invoke('plugin:instance|instance_list_screenshot_groups')
}
export async function create_screenshot_group(
name: string,
screenshotIds: string[],
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_create_screenshot_group', {
name,
ids: screenshotIds,
})
}
export async function rename_screenshot_group(
id: string,
newName: string,
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
}
export async function delete_screenshot_group(id: string): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
}
export async function set_screenshot_group_memberships(
updates: ScreenshotGroupMembershipUpdate[],
): Promise<void> {
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
}
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
return await invoke('plugin:instance|instance_import_screenshot_groups', { groups })
}
export async function save_edited_screenshot(
key: ScreenshotKey,
pngBytes: Uint8Array,
mode: 'create_copy' | 'replace_edit',
): Promise<InstanceScreenshot> {
return await invoke('plugin:instance|instance_save_edited_screenshot', {
key,
bytes: Array.from(pngBytes),
mode,
})
}
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
}
export async function export_screenshots(keys: ScreenshotKey[], path: string): Promise<void> {
return await invoke('plugin:instance|instance_export_screenshots', { keys, path })
}
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
return await invoke('plugin:instance|instance_open_screenshot', { key })
}
export async function move_screenshots(
keys: ScreenshotKey[],
targetInstanceId: string,
): Promise<ScreenshotKey[]> {
return await invoke('plugin:instance|instance_move_screenshots', {
keys,
target: targetInstanceId,
})
}
export function getInstanceIconUrl(iconPath?: string | null): string | undefined {
return iconPath ? convertFileSrc(iconPath) : undefined
}
+3 -8
View File
@@ -64,14 +64,9 @@ export async function get_latest_log_cursor(instanceId, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { instanceId, cursor })
}
/// Read Minecraft's logs/latest.log from a cursor.
export async function get_minecraft_latest_log_cursor(instanceId, cursor) {
return await invoke('plugin:logs|logs_get_minecraft_latest_log_cursor', { instanceId, cursor })
}
/// Get all buffered live log lines for an instance from the Rust ring buffer
export async function get_live_log_buffer(instanceId) {
return await invoke('plugin:logs|logs_get_live_log_buffer', { instanceId })
/// Get the authoritative sequenced live-log snapshot from the Rust ring buffer.
export async function get_live_log_snapshot(instanceId) {
return await invoke('plugin:logs|logs_get_live_log_snapshot', { instanceId })
}
/// Clear the live log buffer for an instance on the Rust side
@@ -172,6 +172,7 @@ export type AppSettings = {
home_layout: HomeLayout
minimal_home_instance_id: string | null
close_behavior: 'ask' | 'close' | 'lightweight'
ui_scale: number
home_widgets: HomeDashboardConfig | null
terracotta_public_nodes: string[]
@@ -213,6 +214,7 @@ type LegacyMirrorSettings = {
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
settings.close_behavior ??= 'ask'
settings.ui_scale ??= 100
const hasLegacySettings =
typeof settings.use_minecraft_mirror === 'boolean' &&
typeof settings.use_modrinth_mirror === 'boolean' &&
+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)
})
}
File diff suppressed because it is too large. Load diff
@@ -805,6 +805,12 @@
"app.appearance-settings.transparent-background.title": {
"message": "透明背景"
},
"app.appearance-settings.ui-scale.title": {
"message": "启动器缩放"
},
"app.appearance-settings.ui-scale.description": {
"message": "缩放整个启动器界面。缩小后能显示更多内容,适合内嵌网页被裁切的情况。"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "如果你尝试安装一个未托管在 Modrinth 上的 Modrinth 整合包文件(.mrpack),我们会确保你在安装之前了解相关风险。"
},
@@ -6055,6 +6061,36 @@
"app.onboarding.welcome.title": {
"message": "一切就绪"
},
"app.onboarding.home-intro.title": {
"message": "欢迎回家"
},
"app.onboarding.home-intro.description": {
"message": "这里是你的主页,展示你的实例、最近活动和快速开始游戏的入口。"
},
"app.onboarding.account-login.title": {
"message": "登录 StarLight"
},
"app.onboarding.account-login.description": {
"message": "在这里登录 StarLight 皮肤站,即可使用皮肤站玩家、安装托管整合包并同步你的账号。"
},
"app.onboarding.announcement.title": {
"message": "最新公告"
},
"app.onboarding.announcement.description": {
"message": "打开侧栏的公告入口,查看最新消息、服规与服务器通知。"
},
"app.onboarding.create-starlight.title": {
"message": "创建 StarLight 实例"
},
"app.onboarding.create-starlight.description": {
"message": "点击这里的“创建新实例”。StarLight 实例会安装服务器指定的整合包与版本,并在每次启动前保持更新。"
},
"app.onboarding.settings-updates.title": {
"message": "保持最新"
},
"app.onboarding.settings-updates.description": {
"message": "“更新”页用于设置更新通道、检查启动器新版本并查看更新日志。"
},
"app.project.curseforge.loading": {
"message": "正在加载 CurseForge 项目…"
},
@@ -10422,5 +10458,227 @@
},
"app.settings.about.easteregg.game-title": {
"message": "彩蛋小游戏"
},
"app.home.launch-progress.empty": {
"message": "启动游戏后,这里会显示进度。"
},
"app.home.launch-progress.title": {
"message": "启动进度"
},
"app.screenshots.copied": {
"message": "已复制"
},
"app.screenshots.copy": {
"message": "复制图像"
},
"app.screenshots.delete-description": {
"message": "永久删除 {name}?此操作无法撤销。"
},
"app.screenshots.delete-success": {
"message": "截图已删除"
},
"app.screenshots.delete-title": {
"message": "删除截图"
},
"app.screenshots.deselect": {
"message": "取消选择 {name}"
},
"app.screenshots.edit": {
"message": "编辑截图"
},
"app.screenshots.empty-description": {
"message": "你在游戏中拍摄的截图会显示在这里。"
},
"app.screenshots.empty-heading": {
"message": "还没有截图"
},
"app.screenshots.error-heading": {
"message": "加载截图失败"
},
"app.screenshots.go-to-instance": {
"message": "前往实例"
},
"app.screenshots.group-by": {
"message": "分组方式"
},
"app.screenshots.group.all-screenshots": {
"message": "全部截图"
},
"app.screenshots.group.collapse": {
"message": "折叠分组"
},
"app.screenshots.group.custom": {
"message": "自定义分组"
},
"app.screenshots.group.date": {
"message": "日期"
},
"app.screenshots.group.delete": {
"message": "删除分组"
},
"app.screenshots.group.delete-description": {
"message": "此分组中的截图将变为未分组。"
},
"app.screenshots.group.edit": {
"message": "编辑分组名称"
},
"app.screenshots.group.empty": {
"message": "拖放以添加截图。"
},
"app.screenshots.group.expand": {
"message": "展开分组"
},
"app.screenshots.group.instance": {
"message": "实例"
},
"app.screenshots.group.new": {
"message": "新建分组"
},
"app.screenshots.group.none": {
"message": "不分组"
},
"app.screenshots.group.this-month": {
"message": "本月"
},
"app.screenshots.group.this-week": {
"message": "本周"
},
"app.screenshots.group.today": {
"message": "今天"
},
"app.screenshots.group.ungrouped": {
"message": "未分组"
},
"app.screenshots.group.yesterday": {
"message": "昨天"
},
"app.screenshots.heading": {
"message": "截图"
},
"app.screenshots.more-actions": {
"message": "更多操作"
},
"app.screenshots.no-results-description": {
"message": "请尝试其他搜索或筛选条件。"
},
"app.screenshots.no-results-heading": {
"message": "没有匹配的截图"
},
"app.screenshots.preview.instance-and-date": {
"message": "{instance} · {date}"
},
"app.screenshots.search": {
"message": "搜索"
},
"app.screenshots.select": {
"message": "选择 {name}"
},
"app.screenshots.selection.aria-label": {
"message": "已选中的截图"
},
"app.screenshots.selection.delete-description": {
"message": "删除 {count, plural, one {# 张截图} other {# 张截图}}?此操作无法撤销。"
},
"app.screenshots.selection.delete-success": {
"message": "{count, plural, one {已删除 # 张截图} other {已删除 # 张截图}}"
},
"app.screenshots.selection.delete-title": {
"message": "删除选中的截图"
},
"app.screenshots.selection.export-zip": {
"message": "导出 ZIP"
},
"app.screenshots.selection.global-export-filename": {
"message": "Modrinth screenshots.zip"
},
"app.screenshots.selection.instance-export-filename": {
"message": "{instance} screenshots.zip"
},
"app.screenshots.selection.remove-from-group": {
"message": "从分组中移除"
},
"app.screenshots.selection.selected-count": {
"message": "已选中 {count} 张"
},
"app.screenshots.selection.zip-archive": {
"message": "ZIP 压缩包"
},
"app.screenshots.show-in-folder": {
"message": "在文件夹中显示"
},
"app.screenshots.sort-by": {
"message": "排序方式"
},
"app.screenshots.sort.name": {
"message": "名称"
},
"app.screenshots.sort.newest": {
"message": "最新"
},
"app.screenshots.sort.oldest": {
"message": "最旧"
},
"app.settings.about.copyright": {
"message": "版权所有 © StarLight 保留所有权利。"
},
"app.settings.about.game.best": {
"message": "最佳:{score}"
},
"app.settings.about.game.best-label": {
"message": "最佳"
},
"app.settings.about.game.completed": {
"message": "下界之星!"
},
"app.settings.about.game.exit": {
"message": "退出游戏"
},
"app.settings.about.game.game-over": {
"message": "群星坠落!"
},
"app.settings.about.game.highest-level": {
"message": "最高:等级 {level}"
},
"app.settings.about.game.level-label": {
"message": "最高等级"
},
"app.settings.about.game.new-record": {
"message": "新纪录!"
},
"app.settings.about.game.overtime": {
"message": "涨潮!"
},
"app.settings.about.game.overtime-detail": {
"message": "潮水涌回——继续合成!"
},
"app.settings.about.game.overtimes-label": {
"message": "加时次数"
},
"app.settings.about.game.restart": {
"message": "重新开始"
},
"app.settings.about.game.score": {
"message": "得分:{score}"
},
"app.settings.about.game.score-label": {
"message": "得分"
},
"app.settings.about.game.settle": {
"message": "稳定"
},
"app.settings.about.game.settle-title": {
"message": "本局已稳定!"
},
"app.settings.about.game.tap-to-drop": {
"message": "点击投放一颗星"
},
"app.settings.about.game.tide-hint": {
"message": "天空正在坠落——继续合成!"
},
"app.settings.about.game.title": {
"message": "Starlight 合成"
},
"app.starlight-skin.frame-title": {
"message": "StarLight 皮肤站"
}
}
+7 -7
View File
@@ -3093,7 +3093,7 @@ provideBrowseManager({
<template>
<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>
<ButtonStyled size="large" type="transparent">
<button :disabled="translationLoading" @click="toggleTranslation">
@@ -3193,14 +3193,14 @@ provideBrowseManager({
{{ formatMessage(messages.fuzzySearchSuffix) }}
</p>
</div>
<EmptyState
v-if="isWorldMapBrowse && !curseForgeCapability.configured"
type="empty-inbox"
:heading="formatMessage(messages.mapsUnavailable)"
:description="formatMessage(messages.mapsUnavailableDescription)"
/>
</template>
</BrowsePageLayout>
<EmptyState
v-else
type="empty-inbox"
:heading="formatMessage(messages.mapsUnavailable)"
:description="formatMessage(messages.mapsUnavailableDescription)"
/>
<CreationFlowModal
v-if="isServerContext && projectType === 'modpack'"
ref="serverSetupModalRef"
+2 -2
View File
@@ -10,7 +10,7 @@ import { computed, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
@@ -202,7 +202,7 @@ onUnmounted(() => {
>
<HomeLaunchProgress />
<HomePlayInsights />
<HomeDailyChallenge />
<HomeAnnouncementEntry />
<HomeMinecraftNews />
</div>
</Teleport>
@@ -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 -32
View File
@@ -86,13 +86,16 @@ const {
getHistoricalContent,
invalidate,
clearLive,
refreshLive,
flushLive,
acceptsProcessLogs,
resetLive,
appendLive,
resyncLive,
} = 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) {
return [
@@ -219,28 +222,16 @@ if (!props.playing) {
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) => {
if (e.instance_id !== instanceId.value) return
if (e.event === 'launched') {
resetLive()
await resyncLive().catch(console.warn)
clearCrashAnalysis()
invalidate()
selectedLogIndex.value = 0
}
if (e.event === 'finished') {
await refreshLive().catch(console.warn)
flushLive()
await resyncLive().catch(console.warn)
invalidate()
const freshLogs = await getHistoricalLogs()
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(() => {
disposed = true
clearTimeout(refreshTimer)
unlistenLog()
unlistenProcesses()
})
@@ -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,
})
}
+8
View File
@@ -101,6 +101,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Starlight skin' }],
},
},
{
path: '/screenshots',
name: 'Screenshots',
component: () => import('@/pages/Screenshots.vue'),
meta: {
breadcrumb: [{ name: 'Screenshots' }],
},
},
{
path: '/multiplayer',
name: 'Multiplayer',
+1
View File
@@ -14,6 +14,7 @@ export const DEFAULT_FEATURE_FLAGS = {
skip_non_essential_warnings: false,
skip_unknown_pack_warning: false,
i18n_debug: false,
multiplayer_in_app: false,
show_instance_play_time: true,
page_transitions: true,
advanced_filters_collapsed: true,
+2 -8
View File
@@ -1,7 +1,7 @@
[package]
name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging
version = "1.0.0-beta12"
version = "1.0.0-beta15"
edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red"
@@ -56,13 +56,7 @@ zip = { workspace = true }
[dependencies.tauri]
workspace = true
features = [
"devtools",
"macos-private-api",
"protocol-asset",
"tray-icon",
"unstable"
]
features = ["devtools", "macos-private-api", "protocol-asset", "tray-icon", "unstable"]
[build-dependencies]
cc = { workspace = true }
+1 -2
View File
@@ -292,8 +292,7 @@ fn main() {
"logs_delete_logs",
"logs_delete_logs_by_filename",
"logs_get_latest_log_cursor",
"logs_get_minecraft_latest_log_cursor",
"logs_get_live_log_buffer",
"logs_get_live_log_snapshot",
"logs_clear_live_log_buffer",
"logs_analyze_crash",
"logs_get_crash_analysis_ai_settings",
+1 -1
View File
@@ -9,7 +9,7 @@ use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{Emitter, Runtime};
use tauri_plugin_dialog::DialogExt;
use theseus::instance::get_full_path;
use theseus::instance::{get_full_path, list};
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![
hosted_default,
hosted_set_session,
hosted_has_game_account,
hosted_create,
hosted_binding,
hosted_sync,
@@ -65,6 +66,11 @@ pub async fn hosted_default() -> Result<theseus::pack::hosted::Publication> {
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]
pub async fn hosted_create(game_dir_root: Option<String>) -> Result<String> {
Ok(theseus::pack::hosted::create(game_dir_root).await?)
@@ -6,6 +6,9 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tauri::Manager;
use tauri_plugin_fs::FsExt;
use tauri_plugin_opener::OpenerExt;
use theseus::data::{
AppliedContentSetPatch, ContentItem, Dependency,
EditInstance as CoreEditInstance, InstanceInstallCandidate,
@@ -103,6 +106,20 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_edit_icon,
instance_export_mrpack,
instance_get_pack_export_candidates,
instance_list_screenshots,
instance_list_all_screenshots,
instance_list_synced_screenshots,
instance_save_edited_screenshot,
instance_list_screenshot_groups,
instance_create_screenshot_group,
instance_rename_screenshot_group,
instance_delete_screenshot_group,
instance_set_screenshot_group_memberships,
instance_import_screenshot_groups,
instance_delete_screenshots,
instance_export_screenshots,
instance_move_screenshots,
instance_open_screenshot,
])
.build()
}
@@ -1490,3 +1507,171 @@ pub async fn instance_edit_icon(
theseus::instance::edit_icon(instance_id, icon_path).await?;
Ok(())
}
#[derive(Serialize, Debug, Clone)]
pub struct InstanceScreenshot {
pub id: String,
pub instance_id: String,
pub instance_name: String,
pub file_name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub modified_at: i64,
pub group_id: Option<String>,
pub path: PathBuf,
pub url: String,
}
fn serialize_screenshot<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
s: theseus::instance::InstanceScreenshot,
) -> Result<InstanceScreenshot> {
if let Some(parent) = s.path.parent() {
crate::api::files::ensure_browsable(app, parent);
}
let mut url = super::utils::tauri_convert_file_src(&s.path)?;
app.asset_protocol_scope().allow_file(&s.path)?;
app.fs_scope().allow_file(&s.path)?;
url.query_pairs_mut()
.append_pair("revision", &s.modified_at.to_string());
Ok(InstanceScreenshot {
id: s.id,
instance_id: s.instance_id,
instance_name: s.instance_name,
file_name: s.file_name,
created_at: s.created_at,
modified_at: s.modified_at,
group_id: s.group_id,
path: s.path,
url: url.to_string(),
})
}
fn serialize_screenshots<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
items: Vec<theseus::instance::InstanceScreenshot>,
) -> Result<Vec<InstanceScreenshot>> {
items
.into_iter()
.map(|s| serialize_screenshot(app, s))
.collect()
}
#[tauri::command]
pub async fn instance_list_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
instance_id: &str,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_screenshots(instance_id).await?,
)
}
#[tauri::command]
pub async fn instance_list_all_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_all_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_list_synced_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_synced_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_save_edited_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
bytes: Vec<u8>,
mode: theseus::instance::ScreenshotEditSaveMode,
) -> Result<InstanceScreenshot> {
serialize_screenshot(
&app,
theseus::instance::save_edited_screenshot(key, bytes, mode).await?,
)
}
#[tauri::command]
pub async fn instance_list_screenshot_groups()
-> Result<Vec<theseus::instance::ScreenshotGroup>> {
Ok(theseus::instance::list_screenshot_groups().await?)
}
#[tauri::command]
pub async fn instance_create_screenshot_group(
name: String,
ids: Vec<String>,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::create_screenshot_group(name, ids).await?)
}
#[tauri::command]
pub async fn instance_rename_screenshot_group(
id: String,
new_name: String,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::rename_screenshot_group(id, new_name).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshot_group(id: String) -> Result<()> {
Ok(theseus::instance::delete_screenshot_group(id).await?)
}
#[tauri::command]
pub async fn instance_set_screenshot_group_memberships(
updates: Vec<theseus::instance::ScreenshotGroupMembershipUpdate>,
) -> Result<()> {
Ok(theseus::instance::set_screenshot_group_memberships(updates).await?)
}
#[tauri::command]
pub async fn instance_import_screenshot_groups(
groups: Vec<theseus::instance::ScreenshotGroupImport>,
) -> Result<()> {
Ok(theseus::instance::import_screenshot_groups(groups).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
) -> Result<()> {
Ok(theseus::instance::delete_screenshots(&keys).await?)
}
#[tauri::command]
pub async fn instance_export_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
path: PathBuf,
) -> Result<()> {
Ok(theseus::instance::export_screenshots(&keys, path).await?)
}
#[tauri::command]
pub async fn instance_move_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
target: &str,
) -> Result<Vec<theseus::instance::ScreenshotKey>> {
Ok(theseus::instance::move_screenshots(&keys, target).await?)
}
#[tauri::command]
pub async fn instance_open_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
) -> Result<()> {
let path = theseus::instance::get_screenshot_path(&key).await?;
app.opener()
.reveal_item_in_dir(path)
.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(())
}
+5 -18
View File
@@ -4,7 +4,7 @@ use std::path::PathBuf;
use theseus::logs::LogType;
use theseus::logs::{
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_by_filename,
logs_get_latest_log_cursor,
logs_get_minecraft_latest_log_cursor,
logs_get_live_log_buffer,
logs_get_live_log_snapshot,
logs_clear_live_log_buffer,
logs_analyze_crash,
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?)
}
/// 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.
#[tauri::command]
pub async fn logs_get_live_log_buffer(
pub async fn logs_get_live_log_snapshot(
instance_id: &str,
) -> Result<CensoredString> {
Ok(logs::get_live_log_buffer(instance_id).await?)
) -> Result<LiveLogSnapshot> {
Ok(logs::get_live_log_snapshot(instance_id).await?)
}
/// Clear the live log buffer for an instance.
+14 -2
View File
@@ -1,5 +1,5 @@
use crate::api::Result;
use tauri::{Emitter, Runtime};
use tauri::{Emitter, Manager, Runtime};
use theseus::prelude::*;
use theseus::{ProxyConfig, ProxyTestResult};
@@ -12,7 +12,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
cancel_directory_change,
proxy_get,
proxy_set,
proxy_test
proxy_test,
set_ui_scale
])
.build()
}
@@ -37,6 +38,17 @@ pub async fn settings_set(
Ok(())
}
#[tauri::command]
pub async fn set_ui_scale(
app: tauri::AppHandle<impl Runtime>,
factor: f64,
) -> Result<()> {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_zoom(factor);
}
Ok(())
}
#[tauri::command]
pub async fn download_engine_set(
engine: settings::DownloadEngine,
+126 -1
View File
@@ -1,6 +1,131 @@
{
"updated_at": "2026-09-27T00:38:49.605Z",
"updated_at": "2026-10-01T05:02:05.765Z",
"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",
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshots WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE instances\n\t\tSET\n\t\t\tsubmitted_time_played = CASE\n\t\t\t\tWHEN submitted_time_played < 0 THEN ?\n\t\t\t\tWHEN submitted_time_played > ? THEN ?\n\t\t\t\tELSE submitted_time_played + ?\n\t\t\tEND,\n\t\t\trecent_time_played = 0,\n\t\t\tmodified = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "3af5ce6fdc84ce12da06b92950d0249bdc4023c0b3d0c683318079ce93c9dd56"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE screenshots\n\t\tSET instance_id = ?, file_name = ?\n\t\tWHERE instance_id = ? AND file_name = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, name\n\t\tFROM screenshot_groups\n\t\tORDER BY display_order, name, id\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false
]
},
"hash": "4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO screenshot_group_memberships (screenshot_id, group_id)\n\t\tSELECT ?, group_id\n\t\tFROM screenshot_group_memberships\n\t\tWHERE screenshot_id = ?\n\t\tON CONFLICT (screenshot_id) DO UPDATE SET group_id = excluded.group_id\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "5616069c5dd7233ef7cf867ac8367fdc4ff254e929cafce9fd3ff38449e88b89"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE screenshot_groups SET name = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa"
}
@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tscreenshots.id,\n\t\t\tscreenshots.instance_id,\n\t\t\tscreenshots.file_name,\n\t\t\tscreenshots.content_hash,\n\t\t\tscreenshots.file_size,\n\t\t\tscreenshots.modified_at,\n\t\t\tscreenshots.created_at,\n\t\t\tmemberships.group_id AS \"group_id?\"\n\t\tFROM screenshots\n\t\tLEFT JOIN screenshot_group_memberships memberships\n\t\t\tON memberships.screenshot_id = screenshots.id\n\t\tWHERE screenshots.instance_id = ? AND screenshots.file_name = ?\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "instance_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "file_name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "content_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "file_size",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "modified_at",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "created_at",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "group_id?",
"ordinal": 7,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "6f17b1880668e6efb235eaa3808bd64a1c6bb5fc5f3c0082500afdcfa7340efa"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT DISTINCT screenshots.instance_id\n\t\tFROM screenshot_group_memberships memberships\n\t\tINNER JOIN screenshots ON screenshots.id = memberships.screenshot_id\n\t\tWHERE memberships.group_id = ?\n\t\t",
"describe": {
"columns": [
{
"name": "instance_id",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\t\tINSERT INTO screenshot_group_memberships (screenshot_id, group_id)\n\t\t\tVALUES (?, ?)\n\t\t\tON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE screenshot_groups SET display_order = display_order + 1",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO screenshots (\n\t\t\tid,\n\t\t\tinstance_id,\n\t\t\tfile_name,\n\t\t\tcontent_hash,\n\t\t\tfile_size,\n\t\t\tmodified_at,\n\t\t\tcreated_at\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?)\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE screenshots\n\t\tSET\n\t\t\tinstance_id = ?,\n\t\t\tfile_name = ?,\n\t\t\tcontent_hash = ?,\n\t\t\tfile_size = ?,\n\t\t\tmodified_at = ?,\n\t\t\tcreated_at = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT instance_id FROM screenshots WHERE id = ?",
"describe": {
"columns": [
{
"name": "instance_id",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n version\n FROM settings\n ",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n ui_scale,\n version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -294,8 +294,13 @@
"type_info": "Text"
},
{
"name": "version",
"name": "ui_scale",
"ordinal": 58,
"type_info": "Float"
},
{
"name": "version",
"ordinal": 59,
"type_info": "Integer"
}
],
@@ -361,8 +366,9 @@
null,
false,
null,
false,
false
]
},
"hash": "4034abe449274d6b517215f0780ef9685227fb64b061b1a728aebd56cb267f76"
"hash": "a00177609aec1a056ec47d46d4acd6d1a6244434991d3624b70c7734a2ab85c3"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce"
}
@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tscreenshots.id,\n\t\t\tscreenshots.instance_id,\n\t\t\tscreenshots.file_name,\n\t\t\tscreenshots.content_hash,\n\t\t\tscreenshots.file_size,\n\t\t\tscreenshots.modified_at,\n\t\t\tscreenshots.created_at,\n\t\t\tmemberships.group_id AS \"group_id?\"\n\t\tFROM screenshots\n\t\tLEFT JOIN screenshot_group_memberships memberships\n\t\t\tON memberships.screenshot_id = screenshots.id\n\t\tWHERE screenshots.instance_id = ?\n\t\tORDER BY screenshots.file_name, screenshots.id\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "instance_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "file_name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "content_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "file_size",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "modified_at",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "created_at",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "group_id?",
"ordinal": 7,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\t\tINSERT INTO screenshot_groups (id, name, display_order)\n\t\t\tVALUES (?, ?, ?)\n\t\t\tON CONFLICT(id) DO UPDATE SET name = excluded.name\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups",
"describe": {
"columns": [
{
"name": "display_order!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c"
}
+3 -12
View File
@@ -1,7 +1,7 @@
[package]
name = "theseus"
# The actual version is set by the theseus-build workflow on tagging
version = "1.0.0-beta12"
version = "1.0.0-beta15"
edition.workspace = true
[dependencies]
@@ -9,10 +9,7 @@ ariadne = { workspace = true }
async-minecraft-ping = { workspace = true, features = ["srv"] }
async-recursion = { workspace = true }
async-trait = { workspace = true }
async-tungstenite = { workspace = true, features = [
"tokio-runtime",
"tokio-rustls-webpki-roots"
] }
async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rustls-webpki-roots"] }
async-walkdir = { workspace = true }
async_zip = { workspace = true, features = [
"bzip2",
@@ -119,13 +116,7 @@ tokio = { workspace = true, features = [
"time",
] }
tokio-rustls = { workspace = true }
tokio-util = { workspace = true, features = [
"compat",
"io",
"io-util",
"rt",
"time"
] }
tokio-util = { workspace = true, features = ["compat", "io", "io-util", "rt", "time"] }
toml = { workspace = true }
tracing = { workspace = true }
tracing-error = { workspace = true }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN ui_scale REAL NOT NULL DEFAULT 100.0;
@@ -0,0 +1,38 @@
-- Screenshot center: unified index of per-instance screenshots plus
-- user-defined groups. Ported from the Axolotl implementation, trimmed of
-- the instance-sync / game-options tables that Starlight does not use.
CREATE TABLE screenshots (
id TEXT NOT NULL PRIMARY KEY,
instance_id TEXT NOT NULL,
file_name TEXT NOT NULL,
content_hash TEXT NOT NULL,
file_size INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
editor_state TEXT,
UNIQUE (instance_id, file_name),
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
);
CREATE INDEX screenshots_instance_id ON screenshots(instance_id);
CREATE INDEX screenshots_instance_hash ON screenshots(instance_id, content_hash, file_size);
CREATE TABLE screenshot_groups (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
display_order INTEGER NOT NULL DEFAULT 0,
CHECK (length(trim(name)) > 0)
);
CREATE INDEX screenshot_groups_display_order ON screenshot_groups(display_order);
CREATE TABLE screenshot_group_memberships (
screenshot_id TEXT NOT NULL PRIMARY KEY,
group_id TEXT NOT NULL,
FOREIGN KEY (screenshot_id) REFERENCES screenshots(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES screenshot_groups(id) ON DELETE CASCADE
);
CREATE INDEX screenshot_group_memberships_group_id ON screenshot_group_memberships(group_id);
ALTER TABLE settings ADD COLUMN show_screenshots_tab_in_instances INTEGER NOT NULL DEFAULT FALSE CHECK (show_screenshots_tab_in_instances IN (0, 1));
+8 -7
View File
@@ -441,13 +441,14 @@ impl ZipEntrySet {
let mut seen: std::collections::HashSet<&str> =
std::collections::HashSet::new();
for dir in &self.dirs {
if let Some(rest) = dir.strip_prefix(base)
&& let Some((first, _)) = rest.split_once('/')
&& !first.is_empty()
&& !is_noise_entry(first)
&& seen.insert(first)
{
children.push(first.to_string());
if let Some(rest) = dir.strip_prefix(base) {
let first = rest.split('/').next().unwrap_or_default();
if !first.is_empty()
&& !is_noise_entry(first)
&& seen.insert(first)
{
children.push(first.to_string());
}
}
}
for path in &self.files {
@@ -12,6 +12,8 @@ mod paths;
mod planet_minecraft;
mod projects;
mod run;
mod screenshot_groups;
mod screenshots;
mod upgrade;
pub use self::content::{
@@ -70,6 +72,20 @@ pub use self::run::{
GcLaunchIntent, GcLaunchReport, QuickPlayType, kill, run,
run_with_extra_launch_args, run_with_extra_launch_args_with_gc,
};
pub use self::screenshot_groups::{
ScreenshotGroup, ScreenshotGroupImport, ScreenshotGroupMembershipUpdate,
create_screenshot_group, delete_screenshot_group, import_screenshot_groups,
list_screenshot_groups, rename_screenshot_group,
set_screenshot_group_memberships,
};
#[allow(unused_imports)]
pub(crate) use self::screenshots::reconcile_screenshots;
pub use self::screenshots::{
InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey,
delete_screenshots, export_screenshots, get_screenshot_path,
list_all_screenshots, list_screenshots, list_synced_screenshots,
move_screenshots, save_edited_screenshot,
};
pub use self::upgrade::{
dismiss_instance_post_upgrade_notice, execute_instance_upgrade,
get_instance_post_upgrade_notice, get_instance_upgrade_plan,
@@ -0,0 +1,349 @@
use crate::State;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::instances::adapters::sqlite::screenshot_rows;
use serde::{Deserialize, Serialize};
use sqlx::{Sqlite, Transaction};
use std::collections::HashSet;
use uuid::Uuid;
const MAX_GROUP_NAME_LENGTH: usize = 256;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenshotGroup {
pub id: String,
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenshotGroupMembershipUpdate {
pub screenshot_id: String,
pub group_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenshotGroupImport {
pub id: String,
pub name: String,
pub screenshot_ids: Vec<String>,
}
fn validate_group_name(name: &str) -> crate::Result<&str> {
let name = name.trim();
if name.is_empty() {
return Err(crate::ErrorKind::InputError(
"Group name cannot be empty".to_string(),
)
.into());
}
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
return Err(crate::ErrorKind::InputError(format!(
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
))
.into());
}
Ok(name)
}
pub async fn list_screenshot_groups() -> crate::Result<Vec<ScreenshotGroup>> {
let state = State::get().await?;
Ok(screenshot_rows::list_groups(&state.pool)
.await?
.into_iter()
.map(|group| ScreenshotGroup {
id: group.id,
name: group.name,
})
.collect())
}
pub async fn create_screenshot_group(
name: String,
screenshot_ids: Vec<String>,
) -> crate::Result<ScreenshotGroup> {
let name = validate_group_name(&name)?;
let state = State::get().await?;
let id = Uuid::new_v4().to_string();
let mut tx = state.pool.begin().await?;
sqlx::query!(
"UPDATE screenshot_groups SET display_order = display_order + 1",
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)",
id,
name,
)
.execute(&mut *tx)
.await?;
let instance_ids = set_group_members(
&id,
normalize_screenshot_ids(screenshot_ids)?,
&mut tx,
)
.await?;
tx.commit().await?;
emit_screenshot_updates(instance_ids).await;
Ok(ScreenshotGroup {
id,
name: name.to_string(),
})
}
pub async fn rename_screenshot_group(
id: String,
new_name: String,
) -> crate::Result<ScreenshotGroup> {
let new_name = validate_group_name(&new_name)?;
let state = State::get().await?;
let result = sqlx::query!(
"UPDATE screenshot_groups SET name = ? WHERE id = ?",
new_name,
id,
)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::ErrorKind::InputError(format!(
"Unknown screenshot group {id}"
))
.into());
}
Ok(ScreenshotGroup {
id,
name: new_name.to_string(),
})
}
pub async fn delete_screenshot_group(id: String) -> crate::Result<()> {
let state = State::get().await?;
let instance_ids = sqlx::query_scalar!(
"
SELECT DISTINCT screenshots.instance_id
FROM screenshot_group_memberships memberships
INNER JOIN screenshots ON screenshots.id = memberships.screenshot_id
WHERE memberships.group_id = ?
",
id,
)
.fetch_all(&state.pool)
.await?;
let result =
sqlx::query!("DELETE FROM screenshot_groups WHERE id = ?", id,)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::ErrorKind::InputError(format!(
"Unknown screenshot group {id}"
))
.into());
}
emit_screenshot_updates(instance_ids).await;
Ok(())
}
pub async fn set_screenshot_group_memberships(
updates: Vec<ScreenshotGroupMembershipUpdate>,
) -> crate::Result<()> {
let updates = normalize_membership_updates(updates)?;
if updates.is_empty() {
return Ok(());
}
let state = State::get().await?;
let mut tx = state.pool.begin().await?;
let group_ids = updates
.iter()
.filter_map(|update| update.group_id.as_deref())
.collect::<HashSet<_>>();
for group_id in group_ids {
ensure_group_exists(group_id, &mut tx).await?;
}
let mut instance_ids = HashSet::new();
for update in updates {
let instance_id =
screenshot_instance_id(&update.screenshot_id, &mut tx).await?;
instance_ids.insert(instance_id);
sqlx::query!(
"DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?",
update.screenshot_id,
)
.execute(&mut *tx)
.await?;
if let Some(group_id) = update.group_id {
sqlx::query!(
"INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)",
update.screenshot_id,
group_id,
)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
emit_screenshot_updates(instance_ids).await;
Ok(())
}
pub async fn import_screenshot_groups(
groups: Vec<ScreenshotGroupImport>,
) -> crate::Result<()> {
if groups.is_empty() {
return Ok(());
}
let state = State::get().await?;
let mut tx = state.pool.begin().await?;
let base_order = sqlx::query_scalar!(
"SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups",
)
.fetch_one(&mut *tx)
.await?;
let mut group_ids = HashSet::new();
let mut affected_instance_ids = HashSet::new();
for (index, group) in groups.into_iter().enumerate() {
if group.id.trim().is_empty() || !group_ids.insert(group.id.clone()) {
return Err(crate::ErrorKind::InputError(
"Screenshot group import contains an invalid group ID"
.to_string(),
)
.into());
}
let name = validate_group_name(&group.name)?;
let display_order = base_order + index as i64;
sqlx::query!(
"
INSERT INTO screenshot_groups (id, name, display_order)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name
",
group.id,
name,
display_order,
)
.execute(&mut *tx)
.await?;
affected_instance_ids.extend(
set_group_members(
&group.id,
normalize_screenshot_ids(group.screenshot_ids)?,
&mut tx,
)
.await?,
);
}
tx.commit().await?;
emit_screenshot_updates(affected_instance_ids).await;
Ok(())
}
fn normalize_screenshot_ids(
screenshot_ids: Vec<String>,
) -> crate::Result<Vec<String>> {
let mut unique = HashSet::new();
if screenshot_ids
.iter()
.all(|screenshot_id| unique.insert(screenshot_id.clone()))
{
return Ok(screenshot_ids);
}
Err(crate::ErrorKind::InputError(
"Screenshot group contains duplicate screenshots".to_string(),
)
.into())
}
fn normalize_membership_updates(
updates: Vec<ScreenshotGroupMembershipUpdate>,
) -> crate::Result<Vec<ScreenshotGroupMembershipUpdate>> {
let mut screenshot_ids = HashSet::new();
if updates
.iter()
.all(|update| screenshot_ids.insert(update.screenshot_id.clone()))
{
return Ok(updates);
}
Err(crate::ErrorKind::InputError(
"Screenshot membership update contains duplicate screenshots"
.to_string(),
)
.into())
}
async fn set_group_members(
group_id: &str,
screenshot_ids: Vec<String>,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<HashSet<String>> {
let mut instance_ids = HashSet::new();
for screenshot_id in screenshot_ids {
instance_ids.insert(screenshot_instance_id(&screenshot_id, tx).await?);
sqlx::query!(
"
INSERT INTO screenshot_group_memberships (screenshot_id, group_id)
VALUES (?, ?)
ON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id
",
screenshot_id,
group_id,
)
.execute(&mut **tx)
.await?;
}
Ok(instance_ids)
}
async fn ensure_group_exists(
group_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let exists = sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS "exists!: bool""#,
group_id,
)
.fetch_one(&mut **tx)
.await?;
if !exists {
return Err(crate::ErrorKind::InputError(format!(
"Unknown screenshot group {group_id}"
))
.into());
}
Ok(())
}
async fn screenshot_instance_id(
screenshot_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<String> {
sqlx::query_scalar!(
"SELECT instance_id FROM screenshots WHERE id = ?",
screenshot_id,
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown screenshot {screenshot_id}"
))
})
.map_err(Into::into)
}
async fn emit_screenshot_updates(
instance_ids: impl IntoIterator<Item = String>,
) {
for instance_id in instance_ids {
let _ = emit_instance(
&instance_id,
InstancePayloadType::ScreenshotsUpdated,
)
.await;
}
}
@@ -0,0 +1,10 @@
mod operations;
mod reconciliation;
pub use self::operations::{
InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey,
delete_screenshots, export_screenshots, get_screenshot_path,
list_all_screenshots, list_screenshots, list_synced_screenshots,
move_screenshots, save_edited_screenshot,
};
pub(crate) use self::reconciliation::reconcile_screenshots;
@@ -0,0 +1,650 @@
use chrono::{DateTime, Utc};
use futures::stream::{self, StreamExt, TryStreamExt};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use std::path::{Component, Path, PathBuf};
use tokio::sync::OwnedMutexGuard;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};
use super::reconciliation::{
list_source_screenshots, reconcile_source_screenshots,
scan_source_screenshots,
};
use crate::State;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::instances::adapters::sqlite::{
instance_rows::{self, InstanceScreenshotSource},
screenshot_rows,
};
use crate::util::fetch::sha1_file_async;
use crate::util::io::{self, IOError};
const SCREENSHOTS_DIRECTORY: &str = "screenshots";
const SCREENSHOT_SCAN_CONCURRENCY: usize = 8;
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct ScreenshotKey {
pub instance_id: String,
pub file_name: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct InstanceScreenshot {
pub id: String,
pub instance_id: String,
pub instance_name: String,
pub file_name: String,
pub created_at: DateTime<Utc>,
pub modified_at: i64,
pub group_id: Option<String>,
#[serde(skip)]
pub path: PathBuf,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScreenshotEditSaveMode {
CreateCopy,
ReplaceEdit,
}
pub async fn list_screenshots(
instance_id: &str,
) -> crate::Result<Vec<InstanceScreenshot>> {
let state = State::get().await?;
let source =
instance_rows::get_instance_screenshot_source(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
list_source_screenshots(&state, source).await
}
pub async fn list_synced_screenshots() -> crate::Result<Vec<InstanceScreenshot>>
{
list_all_screenshots().await
}
pub async fn list_all_screenshots() -> crate::Result<Vec<InstanceScreenshot>> {
let state = State::get().await?;
let sources = instance_rows::list_screenshot_sources(&state.pool).await?;
list_source_screenshot_sets(&state, sources).await
}
async fn list_source_screenshot_sets(
state: &State,
sources: Vec<InstanceScreenshotSource>,
) -> crate::Result<Vec<InstanceScreenshot>> {
let mut screenshots =
stream::iter(sources.into_iter().map(|source| async move {
list_source_screenshots(state, source).await
}))
.buffer_unordered(SCREENSHOT_SCAN_CONCURRENCY)
.try_collect::<Vec<Vec<InstanceScreenshot>>>()
.await?
.into_iter()
.flatten()
.collect::<Vec<_>>();
sort_screenshots(&mut screenshots);
Ok(screenshots)
}
async fn lock_instance_screenshots<'a>(
state: &State,
instance_ids: impl IntoIterator<Item = &'a str>,
) -> Vec<OwnedMutexGuard<()>> {
let mut locks = Vec::new();
for instance_id in instance_ids {
locks.push(state.lock_instance_screenshots(instance_id).await);
}
locks
}
pub async fn delete_screenshots(keys: &[ScreenshotKey]) -> crate::Result<()> {
ensure_unique_keys(keys)?;
let state = State::get().await?;
let mut instance_ids = keys
.iter()
.map(|key| key.instance_id.as_str())
.collect::<Vec<_>>();
instance_ids.sort_unstable();
instance_ids.dedup();
let _locks = lock_instance_screenshots(&state, instance_ids).await;
for key in keys {
io::remove_file(get_screenshot_path(key).await?).await?;
screenshot_rows::delete_screenshot_by_key(
&key.instance_id,
&key.file_name,
&state.pool,
)
.await?;
}
Ok(())
}
pub async fn export_screenshots(
keys: &[ScreenshotKey],
export_path: PathBuf,
) -> crate::Result<()> {
ensure_unique_keys(keys)?;
if keys.is_empty() {
return Err(crate::ErrorKind::InputError(
"At least one screenshot must be selected".to_string(),
)
.into());
}
let state = State::get().await?;
let mut sources = HashMap::new();
let mut archive_folders = HashMap::<String, String>::new();
let mut used_archive_folders = HashSet::new();
let mut screenshots = Vec::with_capacity(keys.len());
for key in keys {
let source = match sources.get(&key.instance_id) {
Some(source) => source,
None => {
let source = instance_rows::get_instance_screenshot_source(
&key.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
sources.insert(key.instance_id.clone(), source);
&sources[&key.instance_id]
}
};
let archive_folder = match archive_folders.get(&key.instance_id) {
Some(folder) => folder.clone(),
None => {
let folder = unique_archive_folder(
&sanitize_archive_component(&source.name),
&mut used_archive_folders,
);
archive_folders.insert(key.instance_id.clone(), folder.clone());
folder
}
};
let archive_name = format!("{archive_folder}/{}", key.file_name);
screenshots.push((archive_name, get_screenshot_path(key).await?));
}
tokio::task::spawn_blocking(move || -> crate::Result<()> {
let file = std::fs::File::create(&export_path)
.map_err(|error| IOError::with_path(error, &export_path))?;
let mut writer = ZipWriter::new(file);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Stored);
for (archive_name, path) in screenshots {
writer
.start_file(archive_name, options)
.map_err(std::io::Error::from)?;
let mut source = std::fs::File::open(&path)
.map_err(|error| IOError::with_path(error, &path))?;
std::io::copy(&mut source, &mut writer).map_err(IOError::from)?;
}
writer.finish().map_err(std::io::Error::from)?;
Ok(())
})
.await??;
Ok(())
}
pub async fn move_screenshots(
keys: &[ScreenshotKey],
target_instance_id: &str,
) -> crate::Result<Vec<ScreenshotKey>> {
ensure_unique_keys(keys)?;
if keys.is_empty() {
return Ok(Vec::new());
}
let state = State::get().await?;
let mut instance_ids = keys
.iter()
.map(|key| key.instance_id.as_str())
.chain(std::iter::once(target_instance_id))
.collect::<Vec<_>>();
instance_ids.sort_unstable();
instance_ids.dedup();
let _locks = lock_instance_screenshots(&state, instance_ids).await;
let target_source = instance_rows::get_instance_screenshot_source(
target_instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown target instance".to_string())
})?;
let target_dir = source_screenshots_dir(&state, &target_source).await?;
io::create_dir_all(&target_dir).await?;
ensure_directory_is_not_symlink(&target_dir).await?;
let mut moved_keys = Vec::with_capacity(keys.len());
for key in keys {
if key.instance_id == target_instance_id {
continue;
}
let source_path = get_screenshot_path(key).await?;
let target_path =
available_target_path(&target_dir, &key.file_name).await?;
io::rename_or_move(&source_path, &target_path)
.await
.map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not move screenshot: {error}"
))
})?;
let file_name = target_path
.file_name()
.and_then(|file_name| file_name.to_str())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not determine moved screenshot file name"
.to_string(),
)
})?
.to_string();
screenshot_rows::move_screenshot(
&key.instance_id,
&key.file_name,
target_instance_id,
&file_name,
&state.pool,
)
.await?;
moved_keys.push(ScreenshotKey {
instance_id: target_instance_id.to_string(),
file_name,
});
}
Ok(moved_keys)
}
pub async fn get_screenshot_path(
key: &ScreenshotKey,
) -> crate::Result<PathBuf> {
validate_file_name(&key.file_name)?;
let state = State::get().await?;
let source = instance_rows::get_instance_screenshot_source(
&key.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
let canonical_dir = tokio::fs::canonicalize(&screenshots_dir)
.await
.map_err(|error| IOError::with_path(error, &screenshots_dir))?;
let requested_path = screenshots_dir.join(&key.file_name);
let metadata = tokio::fs::symlink_metadata(&requested_path)
.await
.map_err(|error| IOError::with_path(error, &requested_path))?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(crate::ErrorKind::InputError(
"Screenshot must be a regular file".to_string(),
)
.into());
}
let canonical_path = tokio::fs::canonicalize(&requested_path)
.await
.map_err(|error| IOError::with_path(error, &requested_path))?;
if !canonical_path.starts_with(&canonical_dir) {
return Err(crate::ErrorKind::InputError(
"Screenshot path is outside the instance screenshots directory"
.to_string(),
)
.into());
}
Ok(canonical_path)
}
pub async fn save_edited_screenshot(
key: ScreenshotKey,
png_bytes: Vec<u8>,
mode: ScreenshotEditSaveMode,
) -> crate::Result<InstanceScreenshot> {
validate_file_name(&key.file_name)?;
let state = State::get().await?;
let source = instance_rows::get_instance_screenshot_source(
&key.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let _lock = state.lock_instance_screenshots(&source.id).await;
let scanned = scan_source_screenshots(&state, &source).await?;
let current =
reconcile_source_screenshots(&state, &source, scanned).await?;
let source_screenshot = current
.iter()
.find(|screenshot| screenshot.file_name == key.file_name)
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown screenshot".to_string())
})?;
let source_row = screenshot_rows::get_screenshot_by_key(
&key.instance_id,
&key.file_name,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown screenshot".to_string())
})?;
let png_bytes = tokio::task::spawn_blocking(move || {
validate_png_dimensions(&png_bytes)?;
Ok::<_, crate::Error>(png_bytes)
})
.await??;
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
let (target_path, copy_group) = match mode {
ScreenshotEditSaveMode::CreateCopy => (
available_target_path(&screenshots_dir, &source_row.file_name)
.await?,
true,
),
ScreenshotEditSaveMode::ReplaceEdit => {
(source_screenshot.path.clone(), false)
}
};
let target_file_name = target_path
.file_name()
.and_then(|file_name| file_name.to_str())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not determine edited screenshot file name".to_string(),
)
})?
.to_string();
io::write(&target_path, png_bytes).await?;
if !copy_group {
let (file_size, content_hash) = sha1_file_async(&target_path).await?;
let file_size = i64::try_from(file_size).map_err(|_| {
crate::ErrorKind::InputError(
"Screenshot is too large to index".to_string(),
)
})?;
let mut source_row = source_row.clone();
source_row.content_hash = content_hash;
source_row.file_size = file_size;
let mut tx = state.pool.begin().await?;
screenshot_rows::update_screenshot(&source_row, &mut tx).await?;
tx.commit().await?;
}
let scanned = scan_source_screenshots(&state, &source).await?;
let reconciled =
reconcile_source_screenshots(&state, &source, scanned).await?;
let mut saved = reconciled
.into_iter()
.find(|screenshot| screenshot.file_name == target_file_name)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not index edited screenshot".to_string(),
)
})?;
if copy_group {
let result: crate::Result<()> = async {
let mut tx = state.pool.begin().await?;
screenshot_rows::copy_group_membership(
&source_row.id,
&saved.id,
&mut tx,
)
.await?;
tx.commit().await?;
Ok(())
}
.await;
if let Err(error) = result {
let _ = io::remove_file(&target_path).await;
let _ = screenshot_rows::delete_screenshot_by_key(
&source.id,
&target_file_name,
&state.pool,
)
.await;
return Err(error);
}
}
let mut saved_row = screenshot_rows::get_screenshot_by_key(
&saved.instance_id,
&saved.file_name,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not load edited screenshot".to_string(),
)
})?;
if !copy_group {
let mut tx = state.pool.begin().await?;
if saved_row.created_at != source_row.created_at {
saved_row.created_at = source_row.created_at;
screenshot_rows::update_screenshot(&saved_row, &mut tx).await?;
saved.created_at =
DateTime::from_timestamp_millis(source_row.created_at)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Screenshot creation time is out of range"
.to_string(),
)
})?;
}
tx.commit().await?;
}
saved.modified_at = saved_row.modified_at;
saved.group_id = saved_row.group_id;
let _ = emit_instance(
&saved.instance_id,
InstancePayloadType::ScreenshotsUpdated,
)
.await;
Ok(saved)
}
pub(super) async fn source_screenshots_dir(
state: &State,
source: &InstanceScreenshotSource,
) -> crate::Result<PathBuf> {
// Directly associated instances keep their files in the external launcher's
// resolved game directory (which may be the shared `.minecraft` root or an
// isolated version directory). Use the same runtime resolver as launching
// and content browsing so screenshots are read from the directory the game
// actually uses.
let instance_dir =
match instance_rows::get_instance_by_id(&source.id, &state.pool).await?
{
Some(instance) => crate::launcher::linked_game_dir(&instance)
.unwrap_or_else(|| {
state.directories.instances_dir().join(&source.path)
}),
None => state.directories.instances_dir().join(&source.path),
};
let canonical_instance_dir =
tokio::fs::canonicalize(&instance_dir)
.await
.map_err(|error| IOError::with_path(error, &instance_dir))?;
let screenshots_dir = canonical_instance_dir.join(SCREENSHOTS_DIRECTORY);
ensure_directory_is_not_symlink(&screenshots_dir).await?;
Ok(screenshots_dir)
}
async fn ensure_directory_is_not_symlink(path: &Path) -> crate::Result<()> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(crate::ErrorKind::InputError(
"Instance screenshots directory cannot be a symbolic link"
.to_string(),
)
.into())
}
Ok(metadata) if !metadata.is_dir() => {
Err(crate::ErrorKind::InputError(
"Instance screenshots path must be a directory".to_string(),
)
.into())
}
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(IOError::with_path(error, path).into()),
}
}
async fn available_target_path(
target_dir: &Path,
file_name: &str,
) -> crate::Result<PathBuf> {
validate_file_name(file_name)?;
let original = target_dir.join(file_name);
if !tokio::fs::try_exists(&original)
.await
.map_err(|error| IOError::with_path(error, &original))?
{
return Ok(original);
}
let path = Path::new(file_name);
let stem = path.file_stem().and_then(|value| value.to_str()).unwrap();
let extension = path.extension().and_then(|value| value.to_str()).unwrap();
for suffix in 1_u32.. {
let candidate =
target_dir.join(format!("{stem} ({suffix}).{extension}"));
if !tokio::fs::try_exists(&candidate)
.await
.map_err(|error| IOError::with_path(error, &candidate))?
{
return Ok(candidate);
}
}
unreachable!()
}
fn validate_png_dimensions(bytes: &[u8]) -> crate::Result<(u32, u32)> {
let validate = || -> Result<_, png::DecodingError> {
let mut reader = png::Decoder::new(Cursor::new(bytes)).read_info()?;
let dimensions = (reader.info().width, reader.info().height);
while reader.next_row()?.is_some() {}
reader.finish()?;
Ok(dimensions)
};
validate().map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not decode screenshot as PNG: {error}"
))
.into()
})
}
fn ensure_unique_keys(keys: &[ScreenshotKey]) -> crate::Result<()> {
let mut unique = HashSet::new();
if keys.iter().all(|key| unique.insert(key)) {
return Ok(());
}
Err(crate::ErrorKind::InputError(
"Screenshot selection contains duplicates".to_string(),
)
.into())
}
fn validate_file_name(file_name: &str) -> crate::Result<()> {
let path = Path::new(file_name);
let mut components = path.components();
let is_single_file =
matches!(components.next(), Some(Component::Normal(_)))
&& components.next().is_none();
if !is_single_file || !has_png_extension(path) {
return Err(crate::ErrorKind::InputError(
"Invalid screenshot file name".to_string(),
)
.into());
}
Ok(())
}
pub(super) fn has_png_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
}
pub(super) fn sort_screenshots(screenshots: &mut [InstanceScreenshot]) {
screenshots.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| left.instance_name.cmp(&right.instance_name))
.then_with(|| left.file_name.cmp(&right.file_name))
});
}
fn sanitize_archive_component(value: &str) -> String {
let sanitized = value
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => character,
})
.collect::<String>();
if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
"Instance".to_string()
} else {
sanitized
}
}
fn unique_archive_folder(
base: &str,
used_folders: &mut HashSet<String>,
) -> String {
if used_folders.insert(base.to_string()) {
return base.to_string();
}
for suffix in 2_u32.. {
let candidate = format!("{base} ({suffix})");
if used_folders.insert(candidate.clone()) {
return candidate;
}
}
unreachable!()
}
@@ -0,0 +1,303 @@
use super::operations::{
InstanceScreenshot, has_png_extension, sort_screenshots,
source_screenshots_dir,
};
use crate::State;
use crate::state::instances::adapters::sqlite::{
instance_rows::{self, InstanceScreenshotSource},
screenshot_rows::{self, ScreenshotRow},
};
use crate::util::fetch::sha1_file_async;
use crate::util::io::IOError;
use chrono::{DateTime, Utc};
use futures::stream::{self, StreamExt, TryStreamExt};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use uuid::Uuid;
const SCREENSHOT_HASH_CONCURRENCY: usize = 8;
pub(super) struct ScannedScreenshot {
file_name: String,
created_at: DateTime<Utc>,
modified_at: i64,
file_size: i64,
path: PathBuf,
}
struct ResolvedScreenshot {
scanned: ScannedScreenshot,
row: ScreenshotRow,
is_new: bool,
changed: bool,
}
fn resolve_scanned_screenshot(
source: &InstanceScreenshotSource,
scanned: ScannedScreenshot,
content_hash: String,
matched: Option<ScreenshotRow>,
) -> ResolvedScreenshot {
let (mut row, is_new) = match matched {
Some(row) => (row, false),
None => (
ScreenshotRow {
id: Uuid::new_v4().to_string(),
instance_id: source.id.clone(),
file_name: scanned.file_name.clone(),
content_hash: content_hash.clone(),
file_size: scanned.file_size,
modified_at: scanned.modified_at,
created_at: scanned.created_at.timestamp_millis(),
group_id: None,
},
true,
),
};
let changed = !is_new
&& (row.file_name != scanned.file_name
|| row.content_hash != content_hash
|| row.file_size != scanned.file_size
|| row.modified_at != scanned.modified_at
|| row.created_at != scanned.created_at.timestamp_millis());
row.instance_id.clone_from(&source.id);
row.file_name.clone_from(&scanned.file_name);
row.content_hash = content_hash;
row.file_size = scanned.file_size;
row.modified_at = scanned.modified_at;
row.created_at = scanned.created_at.timestamp_millis();
ResolvedScreenshot {
scanned,
row,
is_new,
changed,
}
}
pub(super) async fn list_source_screenshots(
state: &State,
source: InstanceScreenshotSource,
) -> crate::Result<Vec<InstanceScreenshot>> {
let _lock = state.lock_instance_screenshots(&source.id).await;
let scanned = scan_source_screenshots(state, &source).await?;
reconcile_source_screenshots(state, &source, scanned).await
}
#[allow(dead_code)]
pub(crate) async fn reconcile_screenshots(
instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let Some(source) =
instance_rows::get_instance_screenshot_source(instance_id, &state.pool)
.await?
else {
return Ok(());
};
list_source_screenshots(&state, source).await?;
Ok(())
}
pub(super) async fn scan_source_screenshots(
state: &State,
source: &InstanceScreenshotSource,
) -> crate::Result<Vec<ScannedScreenshot>> {
let screenshots_dir = source_screenshots_dir(state, source).await?;
tokio::task::spawn_blocking(move || scan_screenshots_dir(&screenshots_dir))
.await?
}
fn scan_screenshots_dir(
screenshots_dir: &Path,
) -> crate::Result<Vec<ScannedScreenshot>> {
let entries = match std::fs::read_dir(screenshots_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(error) => return Err(error.into()),
};
let mut screenshots = Vec::new();
for entry in entries {
let entry = entry
.map_err(|error| IOError::with_path(error, screenshots_dir))?;
let file_type = entry
.file_type()
.map_err(|error| IOError::with_path(error, entry.path()))?;
if !file_type.is_file() {
continue;
}
let path = entry.path();
if !has_png_extension(&path) {
continue;
}
let Some(file_name) = entry.file_name().to_str().map(str::to_owned)
else {
continue;
};
let metadata = entry
.metadata()
.map_err(|error| IOError::with_path(error, &path))?;
let created_at = metadata
.created()
.or_else(|_| metadata.modified())
.map(DateTime::<Utc>::from)
.map_err(|error| IOError::with_path(error, &path))?;
let modified_at = metadata
.modified()
.map(DateTime::<Utc>::from)
.map(|value| value.timestamp_millis())
.map_err(|error| IOError::with_path(error, &path))?;
let file_size = i64::try_from(metadata.len()).map_err(|_| {
crate::ErrorKind::InputError(
"Screenshot is too large to index".to_string(),
)
})?;
screenshots.push(ScannedScreenshot {
file_name,
created_at,
modified_at,
file_size,
path,
});
}
Ok(screenshots)
}
pub(super) async fn reconcile_source_screenshots(
state: &State,
source: &InstanceScreenshotSource,
scanned: Vec<ScannedScreenshot>,
) -> crate::Result<Vec<InstanceScreenshot>> {
let existing =
screenshot_rows::list_screenshots(&source.id, &state.pool).await?;
let mut unmatched_by_name = existing
.into_iter()
.map(|row| (row.file_name.clone(), row))
.collect::<HashMap<_, _>>();
let mut resolved = Vec::with_capacity(scanned.len());
let mut needs_hash = Vec::new();
for scanned in scanned {
match unmatched_by_name.remove(&scanned.file_name) {
Some(row)
if row.file_size == scanned.file_size
&& row.modified_at == scanned.modified_at
&& row.created_at
== scanned.created_at.timestamp_millis() =>
{
resolved.push(ResolvedScreenshot {
scanned,
row,
is_new: false,
changed: false,
});
}
matched => needs_hash.push((scanned, matched)),
}
}
let hashed = stream::iter(needs_hash.into_iter().map(
|(mut scanned, matched)| async move {
let (file_size, content_hash) =
sha1_file_async(&scanned.path).await?;
scanned.file_size = i64::try_from(file_size).map_err(|_| {
crate::ErrorKind::InputError(
"Screenshot is too large to index".to_string(),
)
})?;
Ok::<_, crate::Error>((scanned, content_hash, matched))
},
))
.buffer_unordered(SCREENSHOT_HASH_CONCURRENCY)
.try_collect::<Vec<_>>()
.await?;
let mut unmatched_by_hash =
HashMap::<(String, i64), Vec<ScreenshotRow>>::new();
for row in unmatched_by_name.into_values() {
unmatched_by_hash
.entry((row.content_hash.clone(), row.file_size))
.or_default()
.push(row);
}
for (scanned, content_hash, matched_by_name) in hashed {
if let Some(row) = matched_by_name {
resolved.push(resolve_scanned_screenshot(
source,
scanned,
content_hash,
Some(row),
));
continue;
}
let hash_key = (content_hash.clone(), scanned.file_size);
let matched = unmatched_by_hash.get_mut(&hash_key).and_then(|rows| {
if rows.is_empty() {
return None;
}
let created_at = scanned.created_at.timestamp_millis();
let index = rows
.iter()
.position(|row| {
row.modified_at == scanned.modified_at
&& row.created_at == created_at
})
.unwrap_or(rows.len() - 1);
Some(rows.swap_remove(index))
});
resolved.push(resolve_scanned_screenshot(
source,
scanned,
content_hash,
matched,
));
}
let mut tx = state.pool.begin().await?;
for row in unmatched_by_hash.into_values().flatten() {
screenshot_rows::delete_screenshot(&row.id, &mut tx).await?;
}
for screenshot in &resolved {
if !screenshot.is_new && screenshot.changed {
let mut row = screenshot.row.clone();
row.file_name = Uuid::new_v4().to_string();
screenshot_rows::update_screenshot(&row, &mut tx).await?;
}
}
for screenshot in &resolved {
if screenshot.is_new {
screenshot_rows::insert_screenshot(&screenshot.row, &mut tx)
.await?;
} else if screenshot.changed {
screenshot_rows::update_screenshot(&screenshot.row, &mut tx)
.await?;
}
}
tx.commit().await?;
let mut screenshots = resolved
.into_iter()
.map(|screenshot| InstanceScreenshot {
id: screenshot.row.id,
instance_id: source.id.clone(),
instance_name: source.name.clone(),
file_name: screenshot.scanned.file_name,
created_at: screenshot.scanned.created_at,
modified_at: screenshot.row.modified_at,
group_id: screenshot.row.group_id,
path: screenshot.scanned.path,
})
.collect::<Vec<_>>();
sort_screenshots(&mut screenshots);
Ok(screenshots)
}
+27 -15
View File
@@ -54,6 +54,18 @@ pub struct LatestLogCursor {
pub new_file: bool,
}
#[derive(Serialize, Debug)]
pub struct LiveLogLine {
pub sequence: u64,
pub message: CensoredString,
}
#[derive(Serialize, Debug)]
pub struct LiveLogSnapshot {
pub lines: Vec<LiveLogLine>,
pub last_sequence: u64,
}
#[derive(Serialize, Debug)] // Not deserialize
#[serde(transparent)]
pub struct CensoredString(String);
@@ -651,31 +663,31 @@ pub async fn delete_logs_by_filename(
}
#[tracing::instrument]
pub async fn get_live_log_buffer(
pub async fn get_live_log_snapshot(
instance_id: &str,
) -> crate::Result<CensoredString> {
) -> crate::Result<LiveLogSnapshot> {
let state = State::get().await?;
let lines = crate::state::get_log_buffer(instance_id);
let joined = lines.join("\n");
let compacted =
cap_log_for_display(compact_duplicate_lines(&joined), false);
let credentials = Credentials::get_all(&state.pool)
.await?
.into_iter()
.map(|x| x.1)
.collect::<Vec<_>>();
maybe_emit_log_compaction_warning("live log", compacted.stats).await;
maybe_emit_log_display_truncation_warning(
"live log",
compacted.display_truncated,
)
.await;
Ok(CensoredString::censor(compacted.output, &credentials))
let snapshot = crate::state::get_live_log_snapshot(instance_id);
Ok(LiveLogSnapshot {
lines: snapshot
.lines
.into_iter()
.map(|line| LiveLogLine {
sequence: line.sequence,
message: CensoredString::censor(line.message, &credentials),
})
.collect(),
last_sequence: snapshot.last_sequence,
})
}
pub fn clear_live_log_buffer(instance_id: &str) {
crate::state::remove_log_buffer(instance_id);
crate::state::clear_log_buffer(instance_id);
}
#[tracing::instrument]
+67 -3
View File
@@ -5,8 +5,9 @@ mod transport;
use crate::{
State,
state::{
AppliedContentSetPatch, EditInstance, InstanceInstallStage,
InstanceLaunchOverridesPatch, InstanceMode, ModLoader,
AppliedContentSetPatch, Credentials, EditInstance,
InstanceInstallStage, InstanceLaunchOverridesPatch, InstanceMode,
MinecraftAccountType, ModLoader,
},
util::fetch::{
DownloadRequest, Integrity, ResourceClass, configured_client,
@@ -31,6 +32,9 @@ use tokio::{
};
const API: &str = "https://skin.starlight.cool/starlight/mod/packs";
const STARLIGHT_YGGDRASIL_API_ROOT: &str =
"https://skin.starlight.cool/yggdrasil";
const ACCOUNT_REQUIRED_MESSAGE: &str = "您需要登录正版或皮肤站账号后才能操作";
const BINDING: &str = ".starlight-pack.json";
const JOURNAL: &str = ".starlight-pack-pending.json";
const MAX_HOSTED_CONCURRENT_FILES: usize = 8;
@@ -426,10 +430,47 @@ pub async fn default_publication() -> crate::Result<Publication> {
})
}
fn is_supported_game_account(
account_type: MinecraftAccountType,
yggdrasil_api_root: Option<&str>,
) -> bool {
account_type == MinecraftAccountType::Microsoft
|| (account_type == MinecraftAccountType::Yggdrasil
&& yggdrasil_api_root.map(|value| value.trim_end_matches('/'))
== Some(STARLIGHT_YGGDRASIL_API_ROOT))
}
async fn has_game_account_in(state: &State) -> crate::Result<bool> {
let accounts = Credentials::get_all_without_refresh(&state.pool).await?;
Ok(accounts.iter().any(|account| {
let account = account.value();
is_supported_game_account(
account.account_type,
account
.yggdrasil
.as_ref()
.map(|value| value.api_root.as_str()),
)
}))
}
pub async fn has_game_account() -> crate::Result<bool> {
let state = State::get().await?;
has_game_account_in(&state).await
}
async fn require_game_account(state: &State) -> crate::Result<()> {
if !has_game_account_in(state).await? {
return Err(invalid(ACCOUNT_REQUIRED_MESSAGE));
}
Ok(())
}
pub async fn create(game_dir_root: Option<String>) -> crate::Result<String> {
let state = State::get().await?;
require_game_account(&state).await?;
let publication = default_publication().await?;
let runtime = &publication.manifest.runtime;
let state = State::get().await?;
// The pack's game files live in their own folder under the chosen root,
// e.g. `<root>/<pack name>`. Avoid a `versions/<name>` layout: that shape
// is reserved for externally linked launcher instances and would make the
@@ -728,6 +769,8 @@ async fn recover(instance_id: &str) -> crate::Result<()> {
}
pub async fn synchronize(instance_id: &str) -> crate::Result<SyncResult> {
let state = State::get().await?;
require_game_account(&state).await?;
let _guard = instance_gate(instance_id).lock_owned().await;
if instance_mode(instance_id).await? != InstanceMode::StarLight {
return Err(invalid(
@@ -1492,6 +1535,27 @@ async fn synchronize_with_progress(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosted_operations_accept_only_microsoft_or_starlight_accounts() {
assert!(is_supported_game_account(
MinecraftAccountType::Microsoft,
None
));
assert!(is_supported_game_account(
MinecraftAccountType::Yggdrasil,
Some("https://skin.starlight.cool/yggdrasil/")
));
assert!(!is_supported_game_account(
MinecraftAccountType::Yggdrasil,
Some("https://auth.example.com/yggdrasil")
));
assert!(!is_supported_game_account(
MinecraftAccountType::Offline,
None
));
}
#[tokio::test]
async fn running_pack_checks_tag_changes_without_writing_live_files() {
let root = tempfile::tempdir().unwrap();
+3
View File
@@ -360,6 +360,7 @@ pub enum InstancePayloadType {
revision: u64,
},
ServersUpdated,
ScreenshotsUpdated,
WorldUpdated {
world: String,
},
@@ -409,6 +410,8 @@ mod log_types {
#[derive(Serialize, Clone)]
pub struct LogPayload {
pub instance_id: String,
pub first_sequence: u64,
pub last_sequence: u64,
#[serde(flatten)]
pub event: LogEvent,
}
+20 -12
View File
@@ -1590,6 +1590,15 @@ mod tests {
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn import_api_trusts_persisted_job_context_and_keeps_other_missing_files_waiting()
{
Box::pin(
run_import_api_trusts_persisted_job_context_and_keeps_other_missing_files_waiting(),
)
.await;
}
#[cfg(not(feature = "tauri"))]
async fn run_import_api_trusts_persisted_job_context_and_keeps_other_missing_files_waiting()
{
crate::event::EventState::init().await.unwrap();
let root = tempfile::tempdir().unwrap().keep();
@@ -1788,18 +1797,17 @@ mod tests {
.await
.unwrap();
assert_eq!(observed.pending, 1);
let (scan_result, manual_result) = tokio::join!(
scan_missing_modpack_files_in_at(
job_id,
&race_downloads,
first_seen + DOWNLOAD_STABILITY_WINDOW,
),
import_missing_modpack_file(
job_id,
first.item_id.clone(),
selected,
)
);
let scan = Box::pin(scan_missing_modpack_files_in_at(
job_id,
&race_downloads,
first_seen + DOWNLOAD_STABILITY_WINDOW,
));
let manual = Box::pin(import_missing_modpack_file(
job_id,
first.item_id.clone(),
selected,
));
let (scan_result, manual_result) = tokio::join!(scan, manual);
let scan_result = scan_result.unwrap();
assert!(manual_result.is_ok());
assert!(
+16 -6
View File
@@ -13,7 +13,10 @@ use crate::state::{
use chrono::{DateTime, Utc};
use modrinth_content_management::{ContentType, ResolutionPreferences};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, path::PathBuf};
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
};
use uuid::Uuid;
pub type InstallModpackPreview = CreatePackInstance;
@@ -483,7 +486,7 @@ mod tests {
assert_eq!(summary.bytes_total, Some(300));
let items = job.download_items();
assert_eq!(items.len(), 3);
assert_eq!(items[0].status, DownloadItemStatus::Verifying);
assert_eq!(items[0].status, DownloadItemStatus::Completed);
assert_eq!(items[0].attempt, Some(1));
assert_eq!(items[0].max_attempts, Some(4));
assert_eq!(
@@ -2395,6 +2398,7 @@ impl InstallJobState {
fn download_items_from_events(&self) -> Vec<DownloadItemSnapshot> {
let mut items = Vec::<DownloadItemSnapshot>::new();
let mut indices = HashMap::<String, usize>::new();
let mut content_items = HashSet::<String>::new();
for event in &self.events {
match &event.kind {
InstallJobEventKind::ContentFileQueued {
@@ -2402,6 +2406,7 @@ impl InstallJobState {
bytes_total,
max_attempts,
} => {
content_items.insert(path.clone());
if let Some(item) = indices
.get(path)
.and_then(|&index| items.get_mut(index))
@@ -2482,6 +2487,7 @@ impl InstallJobState {
attempt,
max_attempts,
} => {
content_items.insert(path.clone());
if let Some(item) = indices
.get(path)
.and_then(|&index| items.get_mut(index))
@@ -2562,10 +2568,14 @@ impl InstallJobState {
.get(path)
.and_then(|&index| items.get_mut(index))
{
// Network transfer completion is not content
// finalization. `ContentFileCompleted` is the event
// that confirms verification and DB registration.
item.status = DownloadItemStatus::Verifying;
// Content downloads still need verification and DB
// registration. Standalone Java/Minecraft requests
// are final once the request itself finishes.
item.status = if content_items.contains(path) {
DownloadItemStatus::Verifying
} else {
DownloadItemStatus::Completed
};
item.bytes_downloaded = *bytes;
item.bytes_total = item.bytes_total.or(Some(*bytes));
}
+3 -4
View File
@@ -1361,12 +1361,11 @@ mod tests {
assert_eq!(persisted.status, InstallJobStatus::Canceled);
assert!(persisted.state.rollback.as_ref().unwrap().content.is_none());
assert!(persisted.state.rollback_error.is_none());
assert_eq!(
crate::state::list_instances(&state.pool)
assert!(
crate::state::get_instance(&instance_id, &state.pool)
.await
.unwrap()
.len(),
1
.is_some()
);
let new_instance = crate::api::instance::create(
+11 -6
View File
@@ -1107,12 +1107,17 @@ mod tests {
"org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209:{classifier}"
)
);
assert!(
plan.destination
.to_string_lossy()
.ends_with(format!("{classifier}.jar").as_str())
);
assert_eq!(plan.sha1.as_deref(), Some("aaa"));
let (declared_path, declared_sha1) = match classifier.as_str() {
"natives-linux" => (
"org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-linux.jar",
"aaa",
),
"natives-osx" => ("x-osx.jar", "bbb"),
"natives-windows" => ("x-win.jar", "ccc"),
other => panic!("unexpected native classifier {other}"),
};
assert!(plan.destination.to_string_lossy().ends_with(declared_path));
assert_eq!(plan.sha1.as_deref(), Some(declared_sha1));
}
#[test]
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more