11 Commits
main ... main

Author SHA1 Message Date
42b0c7e7af fix: 修复未登录皮肤站时启动卡住且不再弹玩家选择
未登录皮肤站时启动游戏会弹窗要求选择/登录玩家。若点击“登录皮肤站”
跳转后不登录直接返回,再次启动不会重新弹窗,而是卡在“正在启动”。

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

修复:
- instance-player 新增哨兵错误 PlayerSelectionNavigatedAwayError。
- signInSkinSite 跳转登录前以该错误 reject 当前选择,使
  prepareInstancePlayer 的任务正常结束、清理 in-flight 记录,下次
  启动会重新弹出玩家选择。
- App.vue 的启动错误处理识别该哨兵:跳转登录是用户主动操作而非启动
  失败,静默中止启动,不弹错误提示。
2026-09-19 20:01:46 +08:00
405d980311 fix: 登录皮肤站后侧栏立即显示玩家选择器
登录 StarLight 皮肤站后,侧栏账户选择器(AccountsCard)不会列出
皮肤站玩家,必须先使用该玩家启动一次实例,玩家才会出现。

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

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

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

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

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

涉及 api/pack/import 下的 generic/mod/instance_json 及各启动器
导入器,以及 install/runner 的调用点。
2026-09-19 18:49:55 +08:00
a477b1fb9a feat: integrate StarLight updates and improve font settings and skin editor loading 2026-09-19 18:14:41 +08:00
a47d309103 fix: 修复整合包失败后的完整重试流程 2026-09-19 00:13:43 +08:00
392f01ea4b fix: 允许完整编辑实例名称 2026-09-18 23:45:58 +08:00
5006f432dc feat: 一键安装支持自选游戏目录
StarLight 实例一键安装此前固定落到启动器默认目录,无法选择游戏数据位置。
现复用创建流程的外部游戏目录能力:点击一键安装后弹出目录选择框,
只允许修改游戏目录,其余参数仍由服务器整合包决定。

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

hosted_create 增加 game_dir_root 参数,前端 hostedCreate/install 同步透传。
2026-09-18 13:04:28 +08:00
51fb0c30f7 fix: 完善整合包同步与启动器交互 2026-09-16 17:01:22 +08:00
96 changed files with 4285 additions and 2069 deletions

View File

@ -198,7 +198,7 @@ jobs:
shell: pwsh
run: |
$version = "${{ github.ref_name }}" -replace '^v', ''
$appName = "Axolotl Launcher"
$appName = (Get-Content apps/app/tauri.conf.json -Raw | ConvertFrom-Json).mainBinaryName
$outDir = "target/release/bundle/nsis"
$buildDir = "target/portable-build"
$publishDir = Join-Path $env:RUNNER_TEMP "windows-assets"
@ -249,6 +249,15 @@ jobs:
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null
Copy-Item -Force $appExe "$buildDir\Axolotl\"
$editorResources = "target/release/resources/blockbench-skin"
foreach ($requiredFile in @('index.html', 'css/setup.css', 'dist/skin.bundle.js.gz')) {
if (!(Test-Path -LiteralPath (Join-Path $editorResources $requiredFile) -PathType Leaf)) {
throw "Missing skin editor resource: $requiredFile"
}
}
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\resources" | Out-Null
Copy-Item -LiteralPath $editorResources -Destination "$buildDir\Axolotl\resources" -Recurse -Force
# Create empty .Axolotl folder to trigger portable mode
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null

131
README.md
View File

@ -1,98 +1,75 @@
# Starlight Launcher — 实例启动性能优化记录
> 内部文档,非上线说明。本分支聚焦「实例启动慢」问题的定位与修复。
<div align="center">
<img src="./apps/app/icons/128x128.png" width="128" height="128" alt="Starlight Launcher Logo" />
<h1>Starlight Launcher</h1>
<p><strong>次世代 Minecraft 桌面客户端,全能、美观、全平台覆盖。</strong></p>
## 背景
<p>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/actions">
<img src="https://img.shields.io/badge/Repo-git.starlight.cool-blue?style=for-the-badge&logo=git" alt="Repository" />
</a>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/releases">
<img src="https://img.shields.io/badge/Releases-Download-green?style=for-the-badge&logo=github" alt="Releases" />
</a>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/stargazers">
<img src="https://img.shields.io/badge/Stars-★-ffb800?style=for-the-badge&logo=github" alt="Stars" />
</a>
<a href="COPYING.md">
<img src="https://img.shields.io/badge/License-GPL_3.0-blue.svg?style=for-the-badge" alt="License" />
</a>
</p>
用户反馈:点击实例启动后,要等很久才看到游戏窗口。
<p>
<a href="https://skin.starlight.cool/">官方网站</a>
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest">下载最新版</a>
<a href="CONTRIBUTING.md">参与贡献</a>
<a href="CODE_OF_CONDUCT.md">行为准则</a>
</p>
</div>
经实测定位,问题由多个独立环节叠加造成,本分支逐项修复。
<details open>
<summary><strong>关于 The Land of StarLight (TLSL)</strong></summary>
## 改动清单
**The Land Of StarLight**(中文全称**星光领域**,英文简称 **TLSL**)成立于 2014 年,是一个主营 Minecraft 相关内容的综合性平台。平台主要负责人为 **Disy**(因名称常被抢注,在多数平台以 **Disy920** 出现)。
### 1. 依赖校验戳缓存(`packages/app-lib/src/launcher/direct_ensure.rs`
TLSL 的前身可追溯至 2012 年初建立的凋灵服务器TWS。经过多次重组与模式调整于 2014 年 10 月定名为**星光服务器StarLight-Server简称 SLS**。2020 年初随着运营模式改变与业务扩展TLSL 正式成立SLS 成为其主营子项目。
**问题**PCL/HMCL 直连实例每次启动都要对全部库文件与资产对象重算 SHA1
实测某整合包118 个库 + 3911 个资产对象,合计约 670 MB机械硬盘顺序读仅
27.8 MB/s串行校验耗时约 **17.7 秒**,占整个启动准备阶段的 97%。
如今 TLSL 旗下拥有 Minecraft 服务器 **StarLight-ServerSLS**、面向 Fabric 端的领地模组 **Enclosure** 等项目,致力于打造一个完善的 Minecraft 交流与开发平台
**方案**新增持久化「已验证」戳缓存key 为文件绝对路径value 为
`(size, mtime)``file_is_current` 先做 stat命中戳则跳过 SHA1 读取;
文件被替换或修改size/mtime 变化)时自动回退到完整 SHA1 校验,**不弱化
损坏检测**。
**Starlight Launcher** 即是面向 **SLS星光服务器** 打造的 Minecraft 桌面启动器。
- 缓存文件:`<caches>/linked-verify-stamps.json`(存 Axolotl 自己的缓存目录,
不污染直连安装目录)
- 首次启动建立缓存仍走完整校验;后续启动(含跨进程重启)直接命中
</details>
**效果**`asset_scan` 15788ms → 62ms`ensure_deps` 整体 18.6s → 0.1s。
---
### 2. 库/资产校验并发化(`packages/app-lib/src/launcher/direct_ensure.rs`
**Starlight Launcher星光启动器** 是一款免费、开源、跨平台的 Minecraft Java 版第三方启动器,支持在一个客户端中搜索、安装和更新来自 Modrinth 与 CurseForge 的模组、整合包、资源包和光影,并提供实例管理、多种账户认证与个性化外观。
在戳缓存基础上,把库扫描与资产扫描从串行 `for` 循环改为
`try_for_each_concurrent`(并发度与下载器一致,`task_concurrency_limit * 2`)。
首次建缓存时也能吃到并发收益。
本项目基于 [Modrinth App](https://github.com/modrinth/code) 构建,并在其基础上由 [Axolotl 启动器](https://github.com/Mystic-Stars/Axolotl) 修改而来,移除了不适用于本项目的商业化模块,专注于提供纯净、无广告的桌面启动体验。
> 并发对机械硬盘的随机小文件读取提升有限IOPS 瓶颈),真正的
> 数量级提升来自上面的戳缓存。
_(注:本项目是调用 Modrinth 公开 API 的独立客户端,与 Rinth, Inc. 无任何关联。)_
### 3. 启动阶段计时埋点(`packages/app-lib/src/api/instance/run.rs`、`packages/app-lib/src/launcher/mod.rs`
## 核心优势
在启动链路插入 `[launch-timing]` 前缀的计时日志,覆盖:
`hosted_prepare_launch` / `hosted_java_arguments` / `resolve_version_info` /
`resolve_java` / `resolve_gc` / `assemble_client` / `ensure_deps`(细分
`dep_lib_scan` / `asset_scan` / `dep_assets` / `dep_log_config`/
`remove_old_natives` / `extract_linked_natives` / `process_spawn`
- **真跨平台体验**:告别繁琐的环境配置,原生支持 Windows、macOS完美兼容 Intel 与 Apple Silicon及各类主流 Linux 发行版。
- **现代化内容生态**:集成 Modrinth 和 CurseForge可在启动器中一键浏览。游戏实例、整合包、模组、资源包及光影均可一键安装与升级彻底告别手动管理依赖的痛苦。
- **高度定制化**:无论是主题色调、背景图片,还是离线皮肤,核心功能与视觉展现均由你自由支配。
- **All in one 全新体验**:启动器内置 “实验室” 功能,囊括种子地图、投影工坊等海量使用工具,带来全新原生轮椅体验。
用于定位瓶颈grep `[launch-timing]` 即可。
## 下载与安装
### 4. 启动过渡动画优化(`apps/app/src/mc_transition.rs`
请前往 [Releases](https://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest) 下载适合你操作系统的最新安装包。
已安装的用户每次均可通过内置的 Tauri 签名校验机制,自动在后台完成更新,无需手动下载安装更新。
**a. 动画时长**400ms → 200ms帧数 50 → 25。缓解卡顿。
| 系统平台 | 推荐下载文件 |
| ----------------------- | ----------------------------------------- |
| **Windows** (10/11 x64) | 下载 `.exe` (NSIS) 安装程序 |
| **macOS** | 下载 `通用 .dmg` 镜像文件 |
| **Linux** (x64) | 提供 `.AppImage``.deb``.rpm` 多种格式 |
**b. 窗口对齐修复**:全屏放大与缩回游戏窗口时,左侧留缝、整体偏右。
根因是无边框窗口的不可见 resize border实测窗口比目标大 16×9px
可见内容从 (8, 4.5) 才开始)。改用 `set_client_rect`,通过
`GetWindowRect` / `GetClientRect` 计算边框并补偿,让**可见客户区**精确
落在目标矩形。
## 参与项目开发
**c. 单次 SetWindowPos**:每帧从两次调用(`set_position` + `set_size`
改为单次 `SetWindowPos`,减少重绘
Starlight Launcher 的进步离不开社区的反馈与贡献。
如果遇到 Bug 或有新的功能点子,欢迎提交 Issue。如需搭建本地开发环境或查阅打包发布规范请阅读详细的 [贡献指南 (CONTRIBUTING.md)](CONTRIBUTING.md)
参与社区和贡献代码前,也请先阅读[行为准则 (CODE_OF_CONDUCT.md)](CODE_OF_CONDUCT.md)。
**d. 恢复可见**:动画前先 `show()` + `unminimize()`,避免轻量模式遗留
导致遮罩建了却不可见。
### 5. 遮罩即时显示(`apps/app/src/lightweight_mode.rs`
**问题**:点击启动后遮罩要等约 10 秒才出现。
**根因**:收到 `launched` 事件后,代码先 `await maximize_minecraft_window`
(最多轮询 5 秒等游戏窗口),跑完才启动遮罩动画。
**方案**:把最大化游戏窗口丢到独立任务,遮罩动画立即启动,两者并行。
### 6. debug 构建写文件日志(`packages/app-lib/src/logger.rs`
**问题**debug 构建的 `start_logger` 只输出到控制台、不落盘GUI 进程
关闭后无法回溯日志,导致性能计时无法采集。
**方案**debug 版 logger 增加一层文件输出,写入与 release 相同的
`launcher_logs` 目录,文件名带 `session_debug_` 前缀。
## 结论与边界
- **启动器侧**:准备阶段已从 18.6s 优化到约 0.1s,拉起进程约 0.6s,已到极限。
- **游戏侧**:窗口出现前约 11s、完整加载约 45.8s230 mod 的 NeoForge 整合包),
属整合包固有成本,任何启动器无法缩短。
- 曾尝试开启 NeoForge 早期窗口(`fml.toml``earlyWindowControl`
该整合包下与 mod 冲突导致更慢11s → 30s**已回滚**。
## 构建
```bash
# debug含计时埋点、文件日志
cargo build -p theseus_gui
# release
cargo build --release -p theseus_gui
```
---

View File

@ -1 +0,0 @@
<svg height="388" width="2500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375.103901073386 57.671"><path d="M57.355.316H16.519C7.396.316 0 7.713 0 16.834V57.671h40.836c9.124 0 16.519-7.396 16.519-16.518V7.38z" fill="#db1f29"/><path d="M13.772 48.104c-4.892 0-4.892-4.892-4.892-4.892V18.746c0-4.893 4.892-4.893 4.892-4.893h9.786c4.894 0 9.786 4.893 9.786 4.893s4.894 0 0 0c0-14.251 4.501 5.285 9.786 0 3.46-3.459 4.893 17.125 4.893 17.125l-4.893-2.444s0-4.894-7.339-9.786c-9.104-6.071-19.572-2.071-19.572 7.338 0 19.196 31.804 12.233 31.804 12.233s0 4.892-4.893 4.892H13.772z" fill="#fff"/><path d="M38.238 13.854c0-1.35 1.095-7.34 2.445-7.34 1.352 0 2.446 5.99 2.446 7.34a2.445 2.445 0 1 1-4.891 0z" fill="#fff"/><path d="M105.714 56.675V21.379L93.342 39.77H87.85L75.479 21.379v35.296h-9.85V.996h7.899l17.109 25.186L107.747.996h7.745v55.679zM149.727 57.671c-7.744 0-14.943-3.021-20.271-8.507-5.375-5.545-8.333-12.763-8.333-20.325 0-7.57 2.959-14.788 8.333-20.325C134.783 3.101 142.169 0 149.727 0c7.636 0 14.858 3.02 20.337 8.502 5.384 5.549 8.343 12.767 8.343 20.336 0 7.562-2.958 14.781-8.332 20.325-5.412 5.489-12.636 8.508-20.348 8.508zm0-47.666c-4.919 0-9.567 1.979-13.088 5.574-3.56 3.484-5.512 8.188-5.512 13.26 0 5.069 1.952 9.77 5.496 13.237 3.531 3.61 8.179 5.59 13.104 5.59 4.947 0 9.774-2.031 13.242-5.573 3.506-3.51 5.433-8.215 5.433-13.254 0-5.042-1.927-9.75-5.426-13.252-3.48-3.55-8.307-5.582-13.249-5.582zM180.215 56.675v-9.396h1.062c16.254 0 19.52-5.682 19.52-14.848V10.236h-20.582V1.071h30.432v31.36c0 16.993-8.783 24.243-29.369 24.243h-1.063zM253.239 56.675l-5.128-13.543h-20.26l-5.204 13.543h-10.633L234.354.312h7.106l22.34 56.363zm-8.762-22.931l-6.491-17.066-6.491 17.066zM301.564 56.675l-24.237-35.068v35.068h-9.853V.996h7.588l24.467 35.376V.996h9.776v55.679zM343.234 57.671c-7.744 0-14.943-3.021-20.271-8.506-5.375-5.546-8.334-12.764-8.334-20.326 0-7.57 2.959-14.788 8.332-20.325C328.29 3.101 335.677 0 343.234 0c7.634 0 14.857 3.02 20.337 8.502a29.986 29.986 0 0 1 4.891 6.671l.842 1.566h-11.782l-.501-.563a12.058 12.058 0 0 0-.539-.591c-3.479-3.549-8.305-5.58-13.247-5.58-4.919 0-9.567 1.979-13.089 5.574-3.559 3.484-5.511 8.188-5.511 13.26 0 5.069 1.952 9.77 5.496 13.237 3.53 3.61 8.179 5.59 13.104 5.59 4.947 0 9.773-2.031 13.24-5.573a18.397 18.397 0 0 0 4.85-8.598h-20.991V23.49h31.095l.14.899c.229 1.469.347 2.965.347 4.45 0 7.562-2.959 14.78-8.331 20.325-5.415 5.488-12.639 8.507-20.351 8.507z"/></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -92,6 +92,7 @@ import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useDropImport } from '@/composables/useDropImport'
@ -112,6 +113,7 @@ import {
} from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance'
import { PlayerSelectionNavigatedAwayError } from '@/helpers/instance-player'
import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
@ -373,12 +375,6 @@ let allowWindowClose = false
let unlistenCloseRequested: (() => void) | undefined
let unlistenLightweightModeError: (() => void) | undefined
let unlistenSystemAccentColor: (() => void) | undefined
let unlistenMcTransitionFadeout: (() => void) | undefined
let unlistenMcTransitionFocus: (() => void) | undefined
let unlistenMcTransitionCover: (() => void) | undefined
let unlistenMcTransitionLogoIn: (() => void) | undefined
let unlistenMcTransitionLogoOut: (() => void) | undefined
let unlistenMcTransitionReset: (() => void) | undefined
let maximizedStateTimer: ReturnType<typeof setTimeout> | undefined
let unlistenWindowResize: (() => void) | undefined
const minecraftCrashModal = ref()
@ -497,104 +493,6 @@ onMounted(async () => {
checkUpdates()
void warnIfRunningElevated()
startDirectLinkSync()
unlistenMcTransitionFadeout = await listen('mc-transition-fadeout', async () => {
// 游戏窗口已就位、启动器已缩放到同尺寸。这里做 0.5s CSS 淡出,
// 完成后回调 Rust由后端执行聚焦游戏 + 最小化启动器。
const root = document.getElementById('app')
if (root) {
root.style.transition = 'opacity 500ms ease-out'
root.style.opacity = '0'
}
window.setTimeout(() => {
void invoke('mc_transition_fade_done').catch(() => {})
}, 510)
})
// 窗口恢复可见时,清掉残留的淡出透明度,避免再次打开时全透明。
const resetLauncherOpacity = () => {
const root = document.getElementById('app')
if (root) {
root.style.transition = 'none'
root.style.opacity = ''
void root.offsetHeight
}
}
window.addEventListener('focus', resetLauncherOpacity)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') resetLauncherOpacity()
})
unlistenMcTransitionFocus = await getCurrentWindow().onFocusChanged(({ payload: focused }) => {
if (focused) resetLauncherOpacity()
})
// ============ 启动过渡遮罩(三阶段) ============
// 阶段1纯色遮罩主题色无图—— 盖住放大抖动
// 阶段2全屏后渐显 SVG logo
// 阶段3缩小前背景变 Mojang 红 + 渐隐 logo
let mcCoverEl = null
let mcCoverImg = null
unlistenMcTransitionCover = await listen('mc-transition-cover-show', () => {
const app = document.getElementById('app')
if (!app) return
if (!mcCoverEl) {
mcCoverEl = document.createElement('div')
mcCoverEl.id = 'mc-transition-cover'
mcCoverEl.style.cssText = [
'position: fixed',
'inset: 0',
'z-index: 999999',
'display: flex',
'align-items: center',
'justify-content: center',
'background: var(--color-brand, #db1f29)',
'transition: background-color 500ms ease, opacity 300ms ease-out',
'opacity: 0',
'pointer-events: none',
].join(';')
mcCoverImg = document.createElement('img')
mcCoverImg.src = '/mojang-logo.svg'
mcCoverImg.alt = ''
mcCoverImg.style.cssText =
'width: 42%; max-width: 640px; height: auto; opacity: 0; transition: opacity 500ms ease-out;'
mcCoverEl.appendChild(mcCoverImg)
app.appendChild(mcCoverEl)
}
requestAnimationFrame(() => {
mcCoverEl.style.opacity = '1'
})
window.setTimeout(() => {
void invoke('mc_transition_cover_ready').catch(() => {})
}, 320)
})
unlistenMcTransitionLogoIn = await listen('mc-transition-logo-in', () => {
if (mcCoverImg) mcCoverImg.style.opacity = '1'
window.setTimeout(() => {
void invoke('mc_transition_logo_in_done').catch(() => {})
}, 520)
})
unlistenMcTransitionLogoOut = await listen('mc-transition-logo-out', () => {
if (mcCoverEl) mcCoverEl.style.backgroundColor = '#db1f29'
if (mcCoverImg) mcCoverImg.style.opacity = '0'
window.setTimeout(() => {
void invoke('mc_transition_logo_out_done').catch(() => {})
}, 520)
})
unlistenMcTransitionReset = await listen('mc-transition-reset-opacity', () => {
const app = document.getElementById('app')
if (app) {
app.style.transition = 'none'
app.style.opacity = ''
}
if (mcCoverEl) {
mcCoverEl.remove()
mcCoverEl = null
mcCoverImg = null
}
})
})
let directLinkSync: (() => Promise<void>) | undefined
@ -671,12 +569,6 @@ onUnmounted(async () => {
window.removeEventListener('keydown', handleGlobalKeydown, true)
unlistenCloseRequested?.()
unlistenLightweightModeError?.()
unlistenMcTransitionFadeout?.()
unlistenMcTransitionFocus?.()
unlistenMcTransitionCover?.()
unlistenMcTransitionLogoIn?.()
unlistenMcTransitionLogoOut?.()
unlistenMcTransitionReset?.()
unlistenSystemAccentColor?.()
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
@ -1866,6 +1758,10 @@ async function handleCommand(e) {
} else if (e.event === 'LaunchInstance') {
const instance = await getInstance(e.id).catch(() => null)
const handleLaunchCommandError = async (launchError) => {
// Navigating to the skin-site login to pick a player is a deliberate
// user action, not a launch failure: stay silent and let the user
// re-trigger the launch after signing in.
if (launchError instanceof PlayerSelectionNavigatedAwayError) return
const handled =
(await minecraftCrashModal.value?.handleLaunchError(launchError, {
instance_id: e.id,
@ -2264,6 +2160,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<SkinSiteSessionFrame v-if="stateInitialized" />
<InstancePlayerModal v-if="stateInitialized" />
<div id="teleports"></div>
<div
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

View File

@ -0,0 +1,161 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
max-width="560px"
:on-hide="handleHide"
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.gameDirLabel) }}
</span>
<div class="flex gap-2">
<StyledInput
class="flex-1"
:model-value="selectedPath"
readonly
:placeholder="defaultPath || formatMessage(messages.noSelection)"
/>
<ButtonStyled>
<button type="button" @click="browse">
<FolderOpenIcon />
{{ formatMessage(messages.browse) }}
</button>
</ButtonStyled>
</div>
<button
v-if="defaultPath && selectedPath !== defaultPath"
type="button"
class="self-start text-sm text-brand underline decoration-transparent underline-offset-2 transition-colors hover:decoration-current"
@click="selectedPath = defaultPath"
>
{{ formatMessage(messages.resetDefault) }}
</button>
</div>
<p v-if="previewPath" class="m-0 text-sm text-secondary break-all">
{{ formatMessage(messages.preview, { path: previewPath }) }}
</p>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="!selectedPath" @click="handleConfirm">
<CheckIcon />
{{ formatMessage(messages.confirm) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectFilePicker,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
const emit = defineEmits<{
(e: 'confirm', gameDirRoot: string): void
(e: 'cancel'): void
}>()
const { formatMessage } = useVIntl()
const filePicker = injectFilePicker()
const modal = ref<InstanceType<typeof NewModal>>()
const defaultPath = ref('')
const selectedPath = ref('')
const accepted = ref(false)
const messages = defineMessages({
header: {
id: 'app.hosted-install.game-dir.header',
defaultMessage: 'Choose game directory',
},
description: {
id: 'app.hosted-install.game-dir.description',
defaultMessage:
'StarLight instance data (mods, saves, configs, resource packs) is stored in an external game directory. Pick a root folder — the modpack gets its own subfolder inside it.',
},
gameDirLabel: {
id: 'app.hosted-install.game-dir.label',
defaultMessage: 'Game directory root',
},
browse: {
id: 'app.hosted-install.game-dir.browse',
defaultMessage: 'Browse',
},
noSelection: {
id: 'app.hosted-install.game-dir.no-selection',
defaultMessage: 'No folder selected',
},
resetDefault: {
id: 'app.hosted-install.game-dir.reset-default',
defaultMessage: 'Use default location',
},
preview: {
id: 'app.hosted-install.game-dir.preview',
defaultMessage: 'Game files will be installed to: {path}',
},
confirm: {
id: 'app.hosted-install.game-dir.confirm',
defaultMessage: 'Install',
},
})
const previewPath = computed(() => {
const base = (selectedPath.value ?? '').replace(/[\\/]+$/, '')
return base ? `${base}/<pack name>` : ''
})
async function show() {
if (!defaultPath.value) {
defaultPath.value = await invoke<string>('get_launcher_root_dir').catch(() => '')
}
if (!selectedPath.value) selectedPath.value = defaultPath.value
accepted.value = false
modal.value?.show()
}
async function browse() {
const picked = await filePicker.pickFolder?.()
if (picked?.path) selectedPath.value = picked.path
}
function handleCancel() {
modal.value?.hide()
}
function handleConfirm() {
accepted.value = true
modal.value?.hide()
emit('confirm', selectedPath.value)
}
function handleHide() {
if (!accepted.value) emit('cancel')
}
defineExpose({ show })
</script>

View File

@ -83,20 +83,20 @@
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, inject, ref, watch } from 'vue'
import { useHostedSync } from '@/composables/useHostedSync'
import { injectDownloadManager } from '@/providers/download-manager'
import { useRouter } from 'vue-router'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import { markHostedCreationCompleted } from '@/composables/useHostedCreation'
import { useHostedSync } from '@/composables/useHostedSync'
import { useInstanceMode } from '@/composables/useInstanceMode'
import {
type HostedBinding,
hostedBinding,
hostedDefault,
type HostedPublication,
} from '@/helpers/hosted-packs'
import { injectDownloadManager } from '@/providers/download-manager'
const props = defineProps<{ instanceId: string }>()
const modeQuery = useInstanceMode(() => props.instanceId)
const router = useRouter()
@ -186,7 +186,8 @@ async function load() {
async function sync() {
if (syncing.value || !ready.value || modeQuery.data.value !== 'starlight') return
loadError.value = ''
await task.sync()
const result = await task.sync()
if (result) markHostedCreationCompleted(props.instanceId)
}
watch(syncing, (busy, wasBusy) => {
if (!busy && wasBusy) void load()

View File

@ -0,0 +1,249 @@
<script setup lang="ts">
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
skinSitePlayers,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { users } from '@/helpers/auth'
import { getInstanceMode } from '@/helpers/hosted-packs'
import {
PlayerSelectionNavigatedAwayError,
registerInstancePlayerPicker,
saveInstancePlayer,
waitForSkinSiteSession,
type InstancePlayer,
type PlayerChoice,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
const router = useRouter()
const accounts = ref<PlayerChoice[]>([])
const active = ref(false)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const instanceId = ref('')
const locked = ref<InstancePlayer | null>(null)
const awaitingSkinLogin = ref(false)
let hideForLogin = false
let generation = 0
let resolveSelection: ((player: InstancePlayer) => void) | undefined
let rejectSelection: ((reason: Error) => void) | undefined
const choices = computed(() => {
const skin: PlayerChoice[] =
skinSiteStatus.value === 'signed-in'
? skinSitePlayers.value
.filter((player) => !player.isMojang)
.map((player) => ({
id: player.uuid,
name: player.name,
account_type: 'yggdrasil',
skin_site_user: skinSiteUser.value?.uuid,
head: player.headDataUrl,
}))
: []
return [...skin, ...accounts.value].filter(
(player) =>
!locked.value ||
(player.id.replaceAll('-', '') === locked.value.id.replaceAll('-', '') &&
player.account_type === locked.value.account_type &&
(player.skin_site_user ?? null) === (locked.value.skin_site_user ?? null)),
)
})
async function refresh() {
const revision = ++generation
loading.value = true
error.value = ''
try {
const [available, mode] = await Promise.all([users(), getInstanceMode(instanceId.value)])
if (revision !== generation) return
accounts.value = available
.filter(
(account) =>
account.account_type === 'microsoft' ||
(mode === 'local' && account.account_type === 'offline'),
)
.map((account) => ({
id: account.profile.id,
name: account.profile.name,
account_type: account.account_type,
}))
await waitForSkinSiteSession()
if (revision !== generation) return
if (skinSiteStatus.value === 'signed-in') await requestSkinSitePlayers()
if (skinSiteStatus.value === 'error') throw new Error('无法确认皮肤站登录状态,请重试。')
} catch (cause) {
if (revision === generation)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (revision === generation) loading.value = false
}
}
function cancelled() {
if (hideForLogin) {
hideForLogin = false
return
}
if (!active.value) return
active.value = false
awaitingSkinLogin.value = false
generation++
rejectSelection?.(new Error('已取消选择实例玩家'))
resolveSelection = undefined
rejectSelection = undefined
}
async function select(player: PlayerChoice) {
if (loading.value || saving.value || !active.value) return
saving.value = true
error.value = ''
try {
const { head: _, ...binding } = player
await saveInstancePlayer(instanceId.value, binding)
active.value = false
resolveSelection?.(binding)
resolveSelection = undefined
rejectSelection = undefined
modal.value?.hide()
} catch (cause) {
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
saving.value = false
}
}
function signInSkinSite() {
awaitingSkinLogin.value = true
hideForLogin = true
active.value = false
generation++
// Settle the pending selection so `prepareInstancePlayer` releases its
// in-flight entry; otherwise a later launch would await this forever and
// never re-prompt. The caller treats this sentinel as a silent abort.
rejectSelection?.(new PlayerSelectionNavigatedAwayError())
resolveSelection = undefined
rejectSelection = undefined
openSkinSiteLogin()
modal.value?.hide()
void router.push('/starlight-skin')
}
const unregister = registerInstancePlayerPicker((id, saved) => {
if (active.value) return Promise.reject(new Error('请先完成当前实例的玩家选择。'))
instanceId.value = id
locked.value = saved
accounts.value = []
awaitingSkinLogin.value = false
active.value = true
modal.value?.show()
void refresh()
return new Promise((resolve, reject) => {
resolveSelection = resolve
rejectSelection = reject
})
})
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], () => {
if (!active.value || saving.value) return
if (
awaitingSkinLogin.value &&
skinSiteStatus.value === 'signed-in' &&
(!locked.value?.skin_site_user || locked.value.skin_site_user === skinSiteUser.value?.uuid)
) {
awaitingSkinLogin.value = false
modal.value?.show()
}
void refresh()
})
onUnmounted(() => {
unregister()
cancelled()
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:closable="!saving"
:on-hide="cancelled"
>
<div class="flex flex-col gap-3">
<p class="m-0 text-secondary">
{{
locked
? formatMessage(messages.restore, { name: locked.name })
: formatMessage(messages.remember)
}}
</p>
<p v-if="loading" class="m-0" role="status">{{ formatMessage(messages.loading) }}</p>
<template v-else>
<ButtonStyled v-for="player in choices" :key="`${player.account_type}:${player.id}`">
<button
type="button"
:disabled="saving"
class="flex items-center gap-3"
@click="select(player)"
>
<img
v-if="player.head"
:src="player.head"
alt=""
class="size-8 rounded-md [image-rendering:pixelated]"
/>
<span>{{ player.name }}</span
><span class="text-secondary">{{
formatMessage(
player.account_type === 'microsoft'
? messages.licensed
: player.account_type === 'offline'
? messages.offline
: messages.skin,
)
}}</span>
</button>
</ButtonStyled>
<p v-if="!choices.length && !error && !locked" class="m-0 text-secondary">
{{ formatMessage(skinSiteStatus === 'signed-in' ? messages.empty : messages.signIn) }}
</p>
</template>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
<ButtonStyled v-if="error"
><button :disabled="loading || saving" @click="refresh">
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
<ButtonStyled
v-if="
skinSiteStatus === 'signed-out' ||
(locked?.skin_site_user && locked.skin_site_user !== skinSiteUser?.uuid)
"
><button :disabled="saving" @click="signInSkinSite">
{{ formatMessage(messages.skinLogin) }}
</button></ButtonStyled
>
<div class="flex flex-col items-center gap-2">
<span class="text-secondary">{{ formatMessage(messages.or) }}</span>
<ButtonStyled
><button :disabled="loading || saving" @click="microsoftLogin?.showDeviceLogin()">
{{ formatMessage(messages.useMicrosoft) }}
</button></ButtonStyled
>
</div>
<p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p>
</div>
</NewModal>
<MinecraftLoginModal ref="microsoftLogin" @complete="refresh" />
</template>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
chooseInstancePlayer,
getInstancePlayer,
onInstancePlayerChanged,
type InstancePlayer,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const props = defineProps<{ instanceId: string }>()
const player = ref<InstancePlayer | null>(null)
const busy = ref(false)
const error = ref('')
let generation = 0
const stop = onInstancePlayerChanged((id, saved) => {
if (id === props.instanceId) {
generation++
player.value = saved
busy.value = false
error.value = ''
}
})
onUnmounted(() => {
generation++
stop()
})
watch(
() => props.instanceId,
async (id) => {
const revision = ++generation
player.value = null
error.value = ''
busy.value = true
try {
const saved = await getInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision) error.value = String(cause)
} finally {
if (generation === revision) busy.value = false
}
},
{ immediate: true },
)
async function change() {
if (busy.value) return
const revision = generation
const id = props.instanceId
busy.value = true
error.value = ''
try {
const saved = await chooseInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (generation === revision) busy.value = false
}
}
</script>
<template>
<div class="mb-6 flex flex-col gap-2" data-onboarding-id="instance-player-settings">
<h3 class="m-0 text-contrast">{{ formatMessage(messages.setting) }}</h3>
<p class="m-0 text-secondary">
{{ player ? player.name : formatMessage(messages.firstLaunch) }}
</p>
<ButtonStyled
><button :disabled="busy" @click="change">
{{ formatMessage(busy ? messages.loading : messages.change) }}
</button></ButtonStyled
>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
</div>
</template>

View File

@ -133,14 +133,18 @@ function openDownloads() {
</section>
</div>
<template #actions>
<ButtonStyled
><button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button></ButtonStyled
>
<ButtonStyled
><button @click="modal?.hide()">{{ formatMessage(messages.close) }}</button></ButtonStyled
>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled>
<button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal?.hide()">
{{ formatMessage(messages.close) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,37 @@
import { defineMessages } from '@modrinth/ui'
export const playerMessages = defineMessages({
title: { id: 'app.instance-player.title', defaultMessage: 'Choose an instance player' },
remember: {
id: 'app.instance-player.remember',
defaultMessage:
'This instance will keep using your choice. Switch players in instance settings.',
},
restore: {
id: 'app.instance-player.restore',
defaultMessage: 'Sign in again as {name}. To choose someone else, open instance settings.',
},
loading: { id: 'app.instance-player.loading', defaultMessage: 'Loading signed-in players…' },
licensed: { id: 'app.instance-player.licensed', defaultMessage: 'Minecraft account' },
skin: { id: 'app.instance-player.skin', defaultMessage: 'Skin site player' },
offline: { id: 'app.instance-player.offline', defaultMessage: 'Offline player' },
empty: {
id: 'app.instance-player.empty',
defaultMessage: 'No available skin site players. You can create a player on the skin site.',
},
signIn: { id: 'app.instance-player.sign-in', defaultMessage: 'Sign in to choose a player.' },
retry: { id: 'app.instance-player.retry', defaultMessage: 'Retry' },
skinLogin: { id: 'app.instance-player.skin-login', defaultMessage: 'Sign in to skin site' },
or: { id: 'app.instance-player.or', defaultMessage: 'Or' },
useMicrosoft: {
id: 'app.instance-player.use-microsoft',
defaultMessage: 'Use a Minecraft account',
},
saving: { id: 'app.instance-player.saving', defaultMessage: 'Saving instance player…' },
setting: { id: 'app.instance-player.setting', defaultMessage: 'Instance player' },
firstLaunch: {
id: 'app.instance-player.first-launch',
defaultMessage: 'Choose a player on first launch',
},
change: { id: 'app.instance-player.change', defaultMessage: 'Switch instance player' },
})

View File

@ -291,6 +291,7 @@ import {
users,
} from '@/helpers/auth'
import { process_listener } from '@/helpers/events'
import { registerSkinSitePlayers } from '@/helpers/instance-player'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
@ -630,11 +631,22 @@ async function setAccount(account: MinecraftCredential) {
}
watch(
[skinSitePlayers, defaultUser],
([availablePlayers, selectedLocalUser]) => {
[skinSitePlayers, defaultUser, skinSiteUser],
([availablePlayers, selectedLocalUser, siteUser]) => {
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
selectSkinSitePlayer(availablePlayers[0].uuid)
}
// Register skin-site players as launcher accounts as soon as they are
// available, so the account picker shows them without requiring a first
// launch. `registerSkinSitePlayers` is idempotent and best-effort.
if (siteUser?.uuid && availablePlayers.length > 0) {
const pendingIds = availablePlayers.map((player) => player.uuid)
void registerSkinSitePlayers(pendingIds, siteUser.uuid)
.then(() => refreshValues())
.catch((error) => {
console.warn('Failed to register skin site players:', error)
})
}
},
{ immediate: true },
)

View File

@ -22,7 +22,8 @@ const { formatMessage } = useVIntl()
const viewport = ref<HTMLElement>()
const grid = ref<HTMLElement>()
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
const brushCursor = ref({ x: 0, y: 0, color: '', visible: false })
const brushCursor = ref<string>()
const brushActive = computed(() => props.playing && props.selected >= 0)
const large = computed(() => props.puzzle.difficulty !== 'easy')
const visibleCells = computed(() =>
props.puzzle.answer.flatMap((color, i) =>
@ -87,37 +88,21 @@ function onWheel(event: WheelEvent) {
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
}
function moveBrushCursor(event: PointerEvent) {
if (event.pointerType === 'touch') {
hideBrushCursor()
return
}
const cell = (event.target as Element | null)?.closest<HTMLButtonElement>('.mine-cell')
const visible = Boolean(
props.selected >= 0 && cell && !cell.disabled && !cell.classList.contains('mine-open'),
)
brushCursor.value = {
x: event.clientX,
y: event.clientY,
color: visible ? getComputedStyle(event.currentTarget as HTMLElement).color : '',
visible,
}
let cursorColor = ''
function updateBrushCursor() {
if (!brushActive.value || !grid.value) return
const color = getComputedStyle(grid.value).color
if (color === cursorColor) return
cursorColor = color
// Let the native cursor follow the pointer independently of Vue and board rendering.
// The hotspot is the same brush tip as the previous 32px floating SVG.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="${color}" d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"/></svg>`
brushCursor.value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 5 27, crosshair`
}
function hideBrushCursor() {
brushCursor.value.visible = false
}
watch(
() => [props.playing, props.selected] as const,
([playing, selected]) => {
if (!playing || selected < 0) hideBrushCursor()
else if (brushCursor.value.visible && grid.value) {
brushCursor.value.color = getComputedStyle(grid.value).color
}
},
{ flush: 'post' },
)
watch(() => [props.playing, props.selected], updateBrushCursor, {
flush: 'post',
})
function label(index: number) {
const values = {
@ -135,6 +120,7 @@ function label(index: number) {
let observer: ResizeObserver | undefined
onMounted(() => {
updateBrushCursor()
observer = new ResizeObserver(updateView)
if (viewport.value) observer.observe(viewport.value)
})
@ -142,7 +128,10 @@ onScopeDispose(() => observer?.disconnect())
defineExpose({
centerFirstClue,
setView,
getView: () => ({ left: viewport.value?.scrollLeft ?? 0, top: viewport.value?.scrollTop ?? 0 }),
getView: () => ({
left: viewport.value?.scrollLeft ?? 0,
top: viewport.value?.scrollTop ?? 0,
}),
})
</script>
@ -152,16 +141,18 @@ defineExpose({
<div
ref="grid"
class="mine-grid"
:class="{ 'mine-grid-small': !large, 'mine-grid-brush-active': selected >= 0 }"
:class="{
'mine-grid-small': !large,
'mine-grid-brush-active': brushActive,
}"
:style="{
'--mine-size': puzzle.size,
'--mine-cell-size': `${40 * zoom}px`,
'--mine-brush-cursor': brushCursor,
color: selected >= 0 ? `var(--mine-color-${selected})` : undefined,
}"
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
@pointermove="moveBrushCursor"
@pointerleave="hideBrushCursor"
@pointercancel="hideBrushCursor"
@pointerenter="updateBrushCursor"
>
<button
v-for="(_, i) in puzzle.answer"
@ -190,24 +181,6 @@ defineExpose({
</button>
</div>
</div>
<Teleport to="body">
<svg
v-show="brushCursor.visible"
class="mine-brush-cursor"
:style="{
left: `${brushCursor.x}px`,
top: `${brushCursor.y}px`,
color: brushCursor.color,
}"
viewBox="0 0 1024 1024"
aria-hidden="true"
>
<path
fill="currentColor"
d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"
/>
</svg>
</Teleport>
<div v-if="large" class="mine-overview">
<svg
class="mine-map"
@ -260,7 +233,10 @@ defineExpose({
<style scoped>
.mine-navigation {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
display: grid;
gap: var(--gap-md);
}
@ -268,6 +244,9 @@ defineExpose({
grid-template-columns: minmax(0, 1fr) 7rem;
}
.mine-viewport {
box-sizing: border-box;
width: 100%;
max-width: 100%;
overflow: auto;
max-height: min(52vh, 32rem);
min-width: 0;
@ -308,17 +287,9 @@ defineExpose({
user-select: none;
touch-action: manipulation;
}
.mine-grid-brush-active,
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
cursor: none;
}
.mine-brush-cursor {
position: fixed;
z-index: 2147483647;
width: 2rem;
height: 2rem;
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 45%));
pointer-events: none;
transform: translate(-16%, -84%);
cursor: var(--mine-brush-cursor, crosshair);
}
.mine-cell:hover:not(:disabled) {
background: var(--surface-5);
@ -392,12 +363,4 @@ defineExpose({
width: 4.5rem;
}
}
@media (pointer: coarse) {
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
cursor: pointer;
}
.mine-brush-cursor {
display: none;
}
}
</style>

View File

@ -301,6 +301,7 @@ defineExpose({ show })
:header="formatMessage(messages.title)"
width="56rem"
max-width="56rem"
scrollable
:closable="false"
:close-on-esc="false"
actions-divider
@ -455,15 +456,21 @@ defineExpose({ show })
justify-content: space-between;
}
.mine-frame {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
border: 2px solid var(--mine-ink);
border-radius: var(--radius-md);
padding: var(--gap-md);
background: var(--surface-2);
}
.mine-status {
min-width: 0;
max-width: 100%;
margin: 0 0 var(--gap-md);
color: var(--color-contrast);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.mine-actions {
justify-content: flex-end;

View File

@ -16,8 +16,9 @@ import { basename, dirname, join } from '@tauri-apps/api/path'
import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import InstancePlayerSettings from '@/components/instance/InstancePlayerSettings.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
@ -230,7 +231,7 @@ async function setGameDirMode(mode: GameDirMode) {
}
const editInstanceObject = computed(() => ({
name: title.value.trim().substring(0, 32) ?? 'Instance',
name: title.value.trim(),
}))
watch(
@ -381,6 +382,7 @@ const messages = defineMessages({
<template>
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
<InstancePlayerSettings :instance-id="instance.id" />
<ConfirmDeleteInstanceModal
ref="deleteConfirmModal"
:symlink-target="instance.symlink_target"
@ -439,7 +441,6 @@ const messages = defineMessages({
id="instance-name"
v-model="title"
autocomplete="off"
:maxlength="80"
wrapper-class="flex-grow"
/>
</div>

View File

@ -242,7 +242,7 @@ export const onboardingMessages = defineMessages({
instanceActionsDescription: {
id: 'app.onboarding.instance-actions.description',
defaultMessage:
'Launch, stop, repair, configure, export, or open the instance from its header.',
'Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
},
instanceTabsTitle: {
id: 'app.onboarding.instance-tabs.title',

View File

@ -12,6 +12,15 @@ import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
forceUnicodeFont: {
id: 'app.settings.defaults.force-unicode-font',
defaultMessage: 'Force Unicode font',
},
forceUnicodeFontDescription: {
id: 'app.settings.defaults.force-unicode-font-description',
defaultMessage:
'Use the Unicode font when initializing a new instance. Off by default; existing font settings are preserved.',
},
fullscreen: { id: 'app.settings.defaults.fullscreen', defaultMessage: 'Fullscreen' },
fullscreenDescription: {
id: 'app.settings.defaults.fullscreen-description',
@ -205,6 +214,24 @@ watch(
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow>
<template #label>
<span id="settings-target-defaults-unicode-font" tabindex="-1">
{{ formatMessage(messages.forceUnicodeFont) }}
</span>
</template>
<template #description>{{ formatMessage(messages.forceUnicodeFontDescription) }}</template>
<template #control>
<Toggle
id="force-unicode-font"
v-model="settings.force_unicode_font"
:aria-label="formatMessage(messages.forceUnicodeFont)"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow stacked>
<template #label>

View File

@ -294,7 +294,9 @@ async function loadLatestChannelVersions() {
const versions = await Promise.all(
(['release', 'beta'] as const).map(async (channel) => {
try {
const response = await tauriFetch(`https://update.axlmc.org/latest?channel=${channel}`)
const response = await tauriFetch(
`https://skin.starlight.cool/starlight/launcher/latest?channel=${channel}`,
)
if (!response.ok) return [channel, undefined] as const
const payload = (await response.json()) as { version?: string }
return [channel, payload.version] as const

View File

@ -254,6 +254,16 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
targetId: 'settings-target-defaults-environment',
label: message('app.settings.defaults.environment-variables', 'Environment variables'),
},
{
id: 'defaults-unicode-font',
categoryId: 'launch-defaults',
targetId: 'settings-target-defaults-unicode-font',
label: message('app.settings.defaults.force-unicode-font', 'Force Unicode font'),
description: message(
'app.settings.defaults.force-unicode-font-description',
'Use the Unicode font when initializing a new instance. Off by default; existing font settings are preserved.',
),
},
{
id: 'defaults-launch-hooks',
categoryId: 'launch-defaults',

View File

@ -104,6 +104,28 @@ test('settings search index has unique entries with categories', () => {
assert.deepEqual(validateSettingsSearchEntries(), [])
})
test('Unicode font is searchable in Chinese and English', () => {
const entry = settingsSearchEntries.find((entry) => entry.id === 'defaults-unicode-font')!
assert.equal(entry.categoryId, 'launch-defaults')
assert.ok(
readFileSync(new URL('./DefaultInstanceSettings.vue', import.meta.url), 'utf8').includes(
`id="${getSettingsSearchTargetId(entry)}"`,
),
)
for (const translated of [false, true]) {
const documents = settingsSearchEntries.map((entry) => ({
item: entry,
text: translated
? (chineseLocale[entry.label.id]?.message ?? entry.label.defaultMessage ?? '')
: (entry.label.defaultMessage ?? ''),
}))
for (const query of translated ? ['字体', 'Unicode'] : ['font', 'Unicode']) {
const matches = filterSettingsSearchDocuments(query, documents)
assert.ok(matches.some(({ item }) => item.id === 'defaults-unicode-font'))
}
}
})
test('settings search keywords are valid message descriptors', () => {
for (const entry of settingsSearchEntries) {
for (const keyword of entry.keywords ?? []) {

View File

@ -13,7 +13,6 @@ import type { Router } from 'vue-router'
import {
install_job_dismiss,
install_job_repair_cache_and_retry,
install_job_retry,
install_job_support_details,
installJobInstanceId,
type InstallJobSnapshot,
@ -674,7 +673,7 @@ export async function useInstallJobNotifications(opts: {
action: async () => {
if (repairingJobIds.value.has(job.job_id)) return
if (!requiresCacheRepair) {
await install_job_retry(job.job_id).catch(opts.handleError)
await opts.manager.retry(job.job_id).catch(opts.handleError)
await refresh()
return
}

View File

@ -28,6 +28,17 @@ import {
skinSiteUser,
} from './skin-site-session.ts'
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
const frame = { postMessage() {} } as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
resetSkinSiteSession()
setSkinSiteFrame(frame)
assert.equal(skinSiteStatus.value, 'checking')
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('hosted installation sends the JWT to native commands while Local needs no session', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const calls: Array<{ command: string; args: Record<string, unknown> }> = []

View File

@ -1,4 +1,4 @@
import { readonly, ref } from 'vue'
import { readonly, ref, watch } from 'vue'
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
export type SkinSiteUser = { uuid: string; username: string }
@ -113,6 +113,22 @@ export const skinSitePlayersStatus = readonly(playersStatus)
export const selectedSkinSitePlayerId = readonly(selectedPlayerId)
export const skinSiteFrameUrl = readonly(frameUrl)
export async function waitForSkinSiteSession() {
if (status.value !== 'checking') return
await new Promise<void>((resolve, reject) => {
const stop = watch(status, next => {
if (next === 'checking') return
clearTimeout(timer)
stop()
resolve()
})
const timer = setTimeout(() => {
stop()
reject(new Error('皮肤站登录状态仍在检查,请稍后重试。'))
}, 10_000)
})
}
function rejectPendingLuckRequests(message: string) {
for (const request of pendingLuckRequests.values()) {
clearTimeout(request.timeout)
@ -138,6 +154,7 @@ function rejectPendingSkinUpdateRequests(message: string) {
}
export function setSkinSiteFrame(frame: Window | null) {
if (frame && !user.value) status.value = 'checking'
if (connectedFrame === frame) return
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site connection changed.')

View File

@ -1,6 +1,8 @@
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { hostedCreate, hostedSync } from '../helpers/hosted-packs.ts'
import { ref } from 'vue'
import { hostedCreate } from '../helpers/hosted-packs.ts'
import { runHostedSync } from './useHostedSync.ts'
const installing = ref(false)
const installError = ref('')
@ -16,6 +18,18 @@ export function forgetHostedCreation(instanceId: string) {
installError.value = ''
}
export function markHostedCreationCompleted(instanceId: string) {
if (createdInstance.value !== instanceId) return
installError.value = ''
completed.value = true
}
export function markHostedCreationFailed(instanceId: string, cause: unknown) {
if (createdInstance.value !== instanceId) return
completed.value = false
installError.value = String(cause)
}
export function useHostedCreation() {
function acknowledge(instanceId: string) {
if (completed.value && createdInstance.value === instanceId) {
@ -23,7 +37,7 @@ export function useHostedCreation() {
completed.value = false
}
}
async function install() {
async function install(gameDirRoot?: string | null) {
if (installing.value) return
installing.value = true
installError.value = ''
@ -40,14 +54,17 @@ export function useHostedCreation() {
}
}
if (completed.value) return createdInstance.value
createdInstance.value ??= await hostedCreate()
createdInstance.value ??= await hostedCreate(gameDirRoot)
const instanceId = createdInstance.value
await hostedSync(instanceId)
await runHostedSync(instanceId)
if (attempt !== generation) return
completed.value = true
markHostedCreationCompleted(instanceId)
return instanceId
} catch (cause) {
if (attempt === generation) installError.value = String(cause)
if (attempt === generation) {
if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause)
else installError.value = String(cause)
}
} finally {
installing.value = false
}

View File

@ -1,30 +1,59 @@
import { computed, reactive } from 'vue'
import { hostedSync, type HostedSyncResult } from '../helpers/hosted-packs.ts'
const tasks = reactive(
new Map<string, { busy: boolean; result: HostedSyncResult | null; error: string }>(),
)
const inFlight = new Map<string, Promise<HostedSyncResult>>()
function getTask(instanceId: string) {
if (!tasks.has(instanceId)) tasks.set(instanceId, { busy: false, result: null, error: '' })
return tasks.get(instanceId)!
}
/**
* Run one authoritative StarLight synchronization per instance. Every retry
* surface uses this function so the full pack transaction, its progress and
* its final result cannot diverge between pages.
*/
export function runHostedSync(instanceId: string): Promise<HostedSyncResult> {
const existing = inFlight.get(instanceId)
if (existing) return existing
const current = getTask(instanceId)
current.busy = true
current.error = ''
current.result = null
const request = hostedSync(instanceId)
.then((result) => {
current.result = result
return result
})
.catch((error) => {
current.error = String(error)
throw error
})
.finally(() => {
current.busy = false
inFlight.delete(instanceId)
})
inFlight.set(instanceId, request)
return request
}
export function useHostedSync(instanceId: () => string) {
const task = computed(() => {
const id = instanceId()
if (!tasks.has(id)) tasks.set(id, { busy: false, result: null, error: '' })
return tasks.get(id)!
return getTask(instanceId())
})
async function sync() {
const id = instanceId()
const current = task.value
if (current.busy) return
current.busy = true
current.error = ''
current.result = null
if (task.value.busy) return undefined
try {
current.result = await hostedSync(id)
} catch (error) {
current.error = String(error)
} finally {
current.busy = false
return await runHostedSync(id)
} catch {
return undefined
}
}

View File

@ -0,0 +1,124 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
hostedRetryRoute,
hostedRuntimeRetryInstanceId,
retryInstallJob,
} from './hosted-install-retry.ts'
import type { InstallJobSnapshot } from './install.ts'
function job(overrides: Partial<InstallJobSnapshot> = {}): InstallJobSnapshot {
return {
job_id: 'job',
instance_id: 'local:instance',
source_instance_id: null,
instance_deleted: false,
kind: 'install_existing_instance',
status: 'failed',
execution_mode: 'normal',
provider: 'minecraft',
target: { type: 'existing_instance', instance_id: 'local:instance' },
phase: 'downloading_minecraft',
progress: null,
details: { type: 'empty' },
parallel: null,
display: null,
error: null,
rollback_error: null,
pause_reason: null,
upgrade_result: null,
created: '2026-09-18T00:00:00Z',
modified: '2026-09-18T00:00:00Z',
finished: '2026-09-18T00:00:01Z',
summary: {
files_completed: 0,
files_total: null,
bytes_downloaded: 0,
bytes_total: null,
speed_bytes_per_second: null,
eta_seconds: null,
source: null,
fallback_count: 0,
},
items: [],
...overrides,
}
}
test('StarLight runtime retries keep the existing instance identity', () => {
assert.equal(hostedRuntimeRetryInstanceId(job()), 'local:instance')
assert.equal(
hostedRuntimeRetryInstanceId(
job({ instance_id: null, target: { type: 'existing_instance', instance_id: 'target' } }),
),
'target',
)
})
test('deleted and unrelated install jobs do not enter the StarLight runtime retry path', () => {
assert.equal(hostedRuntimeRetryInstanceId(job({ instance_deleted: true })), null)
assert.equal(hostedRuntimeRetryInstanceId(job({ kind: 'create_instance' })), null)
})
test('the hosted retry link targets the current instance content route', () => {
assert.equal(
hostedRetryRoute('local:instance/with space'),
'/instance/local%3Ainstance%2Fwith%20space',
)
assert.doesNotMatch(hostedRetryRoute('local:instance'), /\/mods$/)
})
test('a StarLight runtime failure retries the complete hosted transaction only', async () => {
const calls: string[] = []
const result = await retryInstallJob(job(), {
getInstanceMode: async (instanceId) => {
calls.push(`mode:${instanceId}`)
return 'starlight'
},
retryHosted: async (instanceId, sourceJobId) => {
calls.push(`hosted:${instanceId}:${sourceJobId}`)
},
retryGeneric: async (jobId) => {
calls.push(`generic:${jobId}`)
return job()
},
})
assert.equal(result, null)
assert.deepEqual(calls, ['mode:local:instance', 'hosted:local:instance:job'])
})
test('a Local runtime failure keeps the generic retry path', async () => {
const replacement = job({ job_id: 'replacement', status: 'queued' })
const calls: string[] = []
const result = await retryInstallJob(job(), {
getInstanceMode: async () => 'local',
retryHosted: async () => {
calls.push('hosted')
},
retryGeneric: async (jobId) => {
calls.push(`generic:${jobId}`)
return replacement
},
})
assert.equal(result, replacement)
assert.deepEqual(calls, ['generic:job'])
})
test('mode lookup failures never fall back to an incomplete generic retry', async () => {
let genericCalls = 0
await assert.rejects(
retryInstallJob(job(), {
getInstanceMode: async () => {
throw new Error('instance unavailable')
},
retryHosted: async () => {},
retryGeneric: async () => {
genericCalls++
return job()
},
}),
/instance unavailable/,
)
assert.equal(genericCalls, 0)
})

View File

@ -0,0 +1,34 @@
import type { InstallJobSnapshot } from './install.ts'
interface InstallRetryDependencies {
getInstanceMode: (instanceId: string) => Promise<'starlight' | 'local'>
retryHosted: (instanceId: string, sourceJobId: string) => Promise<void>
retryGeneric: (jobId: string) => Promise<InstallJobSnapshot>
}
/**
* Minecraft installation jobs started by StarLight synchronization are only
* one stage of the full operation. Retrying that stage alone leaves the
* pending pack journal unapplied, so these jobs must resume through
* `hostedSync` instead of the generic install-job retry command.
*/
export function hostedRuntimeRetryInstanceId(job: InstallJobSnapshot): string | null {
if (job.kind !== 'install_existing_instance' || job.instance_deleted) return null
return job.instance_id ?? job.target.instance_id ?? null
}
export function hostedRetryRoute(instanceId: string): string {
return `/instance/${encodeURIComponent(instanceId)}`
}
export async function retryInstallJob(
job: InstallJobSnapshot,
dependencies: InstallRetryDependencies,
): Promise<InstallJobSnapshot | null> {
const instanceId = hostedRuntimeRetryInstanceId(job)
if (instanceId && (await dependencies.getInstanceMode(instanceId)) === 'starlight') {
await dependencies.retryHosted(instanceId, job.job_id)
return null
}
return dependencies.retryGeneric(job.job_id)
}

View File

@ -3,6 +3,7 @@ import {
requestSkinSiteDownloadToken,
skinSiteStatus,
skinSiteUser,
waitForSkinSiteSession,
} from '../composables/skin-site-session.ts'
let sessionUpdate: Promise<void> = Promise.resolve()
@ -28,6 +29,7 @@ export function clearHostedSession(): Promise<void> {
}
export async function prepareHostedSession(): Promise<void> {
await waitForSkinSiteSession()
const userId = skinSiteUser.value?.uuid
const token = await requestSkinSiteDownloadToken()
const update = sessionUpdate
@ -97,7 +99,10 @@ export interface HostedSyncResult {
}
export const hostedDefault = () =>
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
export const hostedCreate = () => invokeWithSession<string>('plugin:install|hosted_create')
export const hostedCreate = (gameDirRoot?: string | null) =>
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) => {

View File

@ -0,0 +1,164 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
import {
setSkinSiteFrame,
resetSkinSiteSession,
receiveSkinSiteMessage,
SKIN_SITE_ORIGIN,
} from '../composables/skin-site-session.ts'
import {
prepareInstancePlayer,
registerInstancePlayerPicker,
saveInstancePlayer,
onInstancePlayerChanged,
type InstancePlayer,
} from './instance-player.ts'
test('instance player selection persists independently from the globally selected account', async () => {
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
useNetworkStatus().refreshBrowserOffline()
const saved: InstancePlayer = { id: 'saved', name: 'Saved', account_type: 'microsoft' }
let binding: InstancePlayer | null = saved
let accounts = [
{ profile: { id: 'other', name: 'Other' }, account_type: 'microsoft' },
{ profile: { id: 'saved', name: 'Saved' }, account_type: 'microsoft' },
]
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
invoke: async (command: string) => {
if (command === 'plugin:auth|get_instance_player') return binding
if (command === 'plugin:auth|get_users') return accounts
throw new Error(`Unexpected command ${command}`)
},
},
},
})
const requests: Array<InstancePlayer | null> = []
const unregister = registerInstancePlayerPicker(async (_id, locked) => {
requests.push(locked)
binding = saved
return saved
})
try {
setSkinSiteFrame({ postMessage() {} } as unknown as Window)
await prepareInstancePlayer('instance')
assert.equal(requests.length, 0)
setSkinSiteFrame(null)
binding = null
await Promise.all([prepareInstancePlayer('instance'), prepareInstancePlayer('instance')])
assert.deepEqual(requests, [null])
await prepareInstancePlayer('instance')
assert.equal(requests.length, 1)
accounts = accounts.filter((account) => account.profile.id !== saved.id)
await prepareInstancePlayer('instance')
assert.equal(
requests[1],
saved,
'Missing saved player must remain locked, not fall back to the other account',
)
} finally {
unregister()
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
else Reflect.deleteProperty(globalThis, 'navigator')
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
}
})
test('signed-in skin site renews the saved player without prompting and only notifies after saving', async () => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
useNetworkStatus().refreshBrowserOffline()
const player: InstancePlayer = {
id: '12345678-1234-1234-1234-123456789abc',
name: 'Skin',
account_type: 'yggdrasil',
skin_site_user: 'owner',
}
const calls: string[] = []
let failSave = false
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'fixture.jwt',
},
} as unknown as MessageEvent,
frame,
)
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
calls.push(command)
if (command.endsWith('get_instance_player')) return player
if (command.endsWith('login_skin_site_player')) {
assert.equal(args.playerId, player.id)
assert.equal(args.userId, 'owner')
assert.equal(args.token, 'fixture.jwt')
} else if (command.endsWith('set_instance_player') && failSave)
throw new Error('Save failed')
},
},
},
})
let prompts = 0
const unregister = registerInstancePlayerPicker(async (_id, locked) => {
prompts++
assert.equal(locked, player)
return player
})
const changes: string[] = []
const stop = onInstancePlayerChanged((id) => changes.push(id))
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'owner', username: 'Owner' },
},
} as unknown as MessageEvent,
frame,
)
await prepareInstancePlayer('one')
assert.equal(prompts, 0)
assert.ok(calls.includes('plugin:auth|login_skin_site_player'))
await saveInstancePlayer('one', player)
assert.deepEqual(changes, ['one'])
failSave = true
await assert.rejects(saveInstancePlayer('two', player), /Save failed/)
assert.deepEqual(changes, ['one'])
resetSkinSiteSession()
await prepareInstancePlayer('one')
assert.equal(prompts, 1)
} finally {
stop()
unregister()
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
else Reflect.deleteProperty(globalThis, 'navigator')
}
})

View File

@ -0,0 +1,162 @@
import { invoke } from '@tauri-apps/api/core'
import {
requestSkinSiteDownloadToken,
skinSiteStatus,
skinSiteUser,
waitForSkinSiteSession,
} from '../composables/skin-site-session.ts'
export { waitForSkinSiteSession } from '../composables/skin-site-session.ts'
import { users } from './auth.js'
import { isOfflineMode } from '../composables/useNetworkStatus.ts'
export type InstancePlayer = {
id: string
name: string
account_type: 'microsoft' | 'yggdrasil' | 'offline'
skin_site_user?: string | null
}
export type PlayerChoice = InstancePlayer & { head?: string }
/**
* Thrown when the player picker is dismissed because the user navigated to the
* skin-site login page. Callers should treat this as a silent launch abort
* (the user will pick a player next time), not as a real failure.
*/
export class PlayerSelectionNavigatedAwayError extends Error {
constructor() {
super('已跳转至皮肤站登录,请登录后重新启动。')
this.name = 'PlayerSelectionNavigatedAwayError'
}
}
type Picker = (instanceId: string, locked: InstancePlayer | null) => Promise<InstancePlayer>
let picker: Picker | undefined
const preparing = new Map<string, Promise<void>>()
const listeners = new Set<(instanceId: string, player: InstancePlayer) => void>()
export function onInstancePlayerChanged(
listener: (instanceId: string, player: InstancePlayer) => void,
) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function registerInstancePlayerPicker(value: Picker) {
picker = value
return () => {
if (picker === value) picker = undefined
}
}
export const getInstancePlayer = (instanceId: string) =>
invoke<InstancePlayer | null>('plugin:auth|get_instance_player', { instanceId })
export async function authenticateInstancePlayer(player: InstancePlayer) {
if (!player.skin_site_user) return
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== player.skin_site_user) {
throw new Error(`请登录实例玩家 ${player.name} 所属的皮肤站账号。`)
}
const token = await requestSkinSiteDownloadToken()
await invoke('plugin:auth|login_skin_site_player', {
token,
playerId: player.id,
userId: player.skin_site_user,
})
if (skinSiteUser.value?.uuid !== player.skin_site_user || skinSiteStatus.value !== 'signed-in') {
throw new Error('皮肤站账号已变化,请重试。')
}
}
/**
* Registers every skin-site player as a launcher account so they show up in the
* account picker immediately after signing in to the skin site, instead of only
* after a first launch. Idempotent: players that already exist in the account
* list (matched by profile UUID) are skipped, and already-signed-in players are
* not re-requested. Best-effort: a single player failing does not abort the rest.
*/
export async function registerSkinSitePlayers(
playerIds: string[],
userUuid: string,
): Promise<void> {
if (playerIds.length === 0) return
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userUuid) return
let known = new Set<string>()
try {
const existing = await users()
known = new Set(
(existing as Array<{ profile?: { id?: string } }>)
.map((account) => account?.profile?.id)
.filter((id): id is string => typeof id === 'string'),
)
} catch {
// If the account list cannot be read, still attempt to register; the
// backend upsert is idempotent.
}
for (const playerId of playerIds) {
if (known.has(playerId)) continue
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userUuid) return
try {
// Request a fresh download token per player: the skin site login
// endpoint may bind a token to a single player id.
const token = await requestSkinSiteDownloadToken()
await invoke('plugin:auth|login_skin_site_player', {
token,
playerId,
userId: userUuid,
})
known.add(playerId)
} catch (error) {
console.warn(`Failed to register skin site player ${playerId}:`, error)
}
}
}
export async function saveInstancePlayer(instanceId: string, player: InstancePlayer) {
await authenticateInstancePlayer(player)
await invoke('plugin:auth|set_instance_player', { instanceId, player })
for (const listener of listeners) listener(instanceId, player)
}
export async function chooseInstancePlayer(
instanceId: string,
locked: InstancePlayer | null = null,
) {
if (!picker) throw new Error('玩家选择界面尚未就绪,请稍后重试。')
return picker(instanceId, locked)
}
export function prepareInstancePlayer(instanceId: string): Promise<void> {
if (isOfflineMode()) return Promise.resolve()
const current = preparing.get(instanceId)
if (current) return current
const task = (async () => {
const saved = await getInstancePlayer(instanceId)
if (saved && !saved.skin_site_user) {
const available = await users()
if (
available.some(
(account) =>
account.profile.id === saved.id && account.account_type === saved.account_type,
)
)
return
}
if (saved?.skin_site_user) await waitForSkinSiteSession()
if (
saved?.skin_site_user &&
skinSiteUser.value?.uuid === saved.skin_site_user &&
skinSiteStatus.value === 'signed-in'
) {
await authenticateInstancePlayer(saved)
return
}
await chooseInstancePlayer(instanceId, saved)
})().finally(() => {
preparing.delete(instanceId)
})
preparing.set(instanceId, task)
return task
}

View File

@ -18,6 +18,7 @@ import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategi
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
import { get_jre, get_memory_status } from '@/helpers/jre.js'
import { get as getSettings } from '@/helpers/settings'
@ -1074,6 +1075,7 @@ export async function run(
instanceId: string,
serverAddress: string | null = null,
): Promise<InstanceRunResult> {
await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {

View File

@ -183,6 +183,7 @@ export type AppSettings = {
custom_env_vars: [string, string][]
memory: MemorySettings
force_fullscreen: boolean
force_unicode_font: boolean
maximize_window: boolean
game_resolution: [number, number]
hide_on_process_start: boolean
@ -227,6 +228,7 @@ function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings)
settings.auto_concurrent_downloads ??= true
settings.download_engine ??= 'legacy'
settings.auto_set_java_high_performance_mode ??= true
settings.force_unicode_font ??= false
settings.minecraft_metadata_source ??=
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_minecraft_mirror)
settings.minecraft_file_source ??=

View File

@ -4,6 +4,8 @@ import { invoke } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { isOfflineMode } from '@/composables/useNetworkStatus'
import { prepareInstancePlayer } from '@/helpers/instance-player'
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
import { get_full_path, resolveGcLaunchIntent } from '@/helpers/instance'
import { openPath } from '@/helpers/utils'
@ -248,6 +250,8 @@ export async function start_join_singleplayer_world(
instanceId: string,
world: string,
): Promise<unknown> {
await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
return await invoke('plugin:worlds|start_join_singleplayer_world', {
instanceId,
@ -259,6 +263,8 @@ export async function start_join_singleplayer_world(
}
export async function start_join_server(instanceId: string, address: string): Promise<unknown> {
await prepareInstancePlayer(instanceId)
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
return await invoke('plugin:worlds|start_join_server', {
instanceId,

View File

@ -1,4 +1,21 @@
{
"app.instance-player.title": { "message": "Choose an instance player" },
"app.instance-player.remember": { "message": "This instance will keep using your choice. Switch players in instance settings." },
"app.instance-player.restore": { "message": "Sign in again as {name}. To choose someone else, open instance settings." },
"app.instance-player.loading": { "message": "Loading signed-in players…" },
"app.instance-player.licensed": { "message": "Minecraft account" },
"app.instance-player.skin": { "message": "Skin site player" },
"app.instance-player.offline": { "message": "Offline player" },
"app.instance-player.empty": { "message": "No available skin site players. You can create a player on the skin site." },
"app.instance-player.sign-in": { "message": "Sign in to choose a player." },
"app.instance-player.retry": { "message": "Retry" },
"app.instance-player.skin-login": { "message": "Sign in to skin site" },
"app.instance-player.or": { "message": "Or" },
"app.instance-player.use-microsoft": { "message": "Use a Minecraft account" },
"app.instance-player.saving": { "message": "Saving instance player…" },
"app.instance-player.setting": { "message": "Instance player" },
"app.instance-player.first-launch": { "message": "Choose a player on first launch" },
"app.instance-player.change": { "message": "Switch instance player" },
"app.hosted-mods.title": { "message": "Updating server Mods" },
"app.hosted-mods.description": { "message": "The game starts after all required updates have been installed." },
"app.hosted-mods.complete": { "message": "Downloaded" },
@ -12,6 +29,14 @@
"app.hosted-packs.auto-installing": { "message": "Downloading and installing the server modpack…" },
"app.hosted-packs.auto-description": { "message": "Download and automatically install the modpack from StarLight to play on the StarLight server with one click." },
"app.hosted-packs.auto-install": { "message": "Install the StarLight modpack" },
"app.hosted-install.game-dir.header": { "message": "Choose game directory" },
"app.hosted-install.game-dir.description": { "message": "StarLight instance data (mods, saves, configs, resource packs) is stored in an external game directory. Pick a root folder — the modpack gets its own subfolder inside it." },
"app.hosted-install.game-dir.label": { "message": "Game directory root" },
"app.hosted-install.game-dir.browse": { "message": "Browse" },
"app.hosted-install.game-dir.no-selection": { "message": "No folder selected" },
"app.hosted-install.game-dir.reset-default": { "message": "Use default location" },
"app.hosted-install.game-dir.preview": { "message": "Game files will be installed to: {path}" },
"app.hosted-install.game-dir.confirm": { "message": "Install" },
"app.onboarding.instance-mode.title": { "message": "Choose your instance type" },
"app.onboarding.instance-mode.description": { "message": "StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player." },
"app.instance-mode.title": { "message": "Instance type" },
@ -5041,6 +5066,9 @@
"app.lab.skin-editor.retry": {
"message": "Try again"
},
"app.lab.skin-editor.resource-error": {
"message": "Skin editor files are missing or damaged. Reinstall the launcher or extract the complete portable archive."
},
"app.lab.skin-editor.title": {
"message": "Skin editor"
},
@ -5861,7 +5889,7 @@
"message": "Switch your home"
},
"app.onboarding.instance-actions.description": {
"message": "Launch, stop, repair, configure, export, or open the instance from its header."
"message": "Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings."
},
"app.onboarding.instance-actions.title": {
"message": "The main controls"
@ -6811,6 +6839,12 @@
"app.settings.defaults.environment-variables-placeholder": {
"message": "Enter environment variables..."
},
"app.settings.defaults.force-unicode-font": {
"message": "Force Unicode font"
},
"app.settings.defaults.force-unicode-font-description": {
"message": "Use the Unicode font when initializing a new instance. Off by default; existing font settings are preserved."
},
"app.settings.defaults.fullscreen": {
"message": "Fullscreen"
},
@ -7721,7 +7755,7 @@
"message": "Tiny Takeover"
},
"app.skins.skin-site-account.description": {
"message": "Select a skin below and apply it directly to {player}. The skin site session remains available while you move between launcher pages."
"message": "Select a skin below and apply it directly to {player}."
},
"app.skins.skin-site-account.empty-description": {
"message": "This skin site account does not have an available player profile yet."

View File

@ -1,4 +1,21 @@
{
"app.instance-player.title": { "message": "选择实例玩家" },
"app.instance-player.remember": { "message": "选择后,此实例将一直使用该玩家。可在实例设置中切换。" },
"app.instance-player.restore": { "message": "请重新登录 {name};要更换玩家,请前往实例设置。" },
"app.instance-player.loading": { "message": "正在读取已登录的玩家…" },
"app.instance-player.licensed": { "message": "正版玩家" },
"app.instance-player.skin": { "message": "皮肤站玩家" },
"app.instance-player.offline": { "message": "离线玩家" },
"app.instance-player.empty": { "message": "当前账号没有可用的皮肤站玩家,可在皮肤站创建玩家。" },
"app.instance-player.sign-in": { "message": "请登录账号后选择玩家。" },
"app.instance-player.retry": { "message": "重试" },
"app.instance-player.skin-login": { "message": "登录皮肤站" },
"app.instance-player.or": { "message": "或者" },
"app.instance-player.use-microsoft": { "message": "使用正版账号" },
"app.instance-player.saving": { "message": "正在保存实例玩家…" },
"app.instance-player.setting": { "message": "实例玩家" },
"app.instance-player.first-launch": { "message": "首次启动时选择玩家" },
"app.instance-player.change": { "message": "切换实例玩家" },
"app.hosted-mods.title": { "message": "正在更新服务器 Mod" },
"app.hosted-mods.description": { "message": "所有更新安装完成后将继续启动游戏。" },
"app.hosted-mods.complete": { "message": "下载完成" },
@ -12,6 +29,14 @@
"app.hosted-packs.auto-installing": { "message": "正在下载并安装服务器整合包…" },
"app.hosted-packs.auto-description": { "message": "从StarLight服务器获取整合包并自动安装可一键游玩StarLight服务器" },
"app.hosted-packs.auto-install": { "message": "安装 StarLight 官方整合包" },
"app.hosted-install.game-dir.header": { "message": "选择游戏目录" },
"app.hosted-install.game-dir.description": { "message": "StarLight 实例的游戏数据mods、存档、配置、资源包会存放在外部游戏目录中。请选择一个根目录整合包会在其中单独建一个子文件夹。" },
"app.hosted-install.game-dir.label": { "message": "游戏目录根路径" },
"app.hosted-install.game-dir.browse": { "message": "浏览" },
"app.hosted-install.game-dir.no-selection": { "message": "尚未选择文件夹" },
"app.hosted-install.game-dir.reset-default": { "message": "使用默认位置" },
"app.hosted-install.game-dir.preview": { "message": "游戏文件将安装到:{path}" },
"app.hosted-install.game-dir.confirm": { "message": "安装" },
"app.onboarding.instance-mode.title": { "message": "选择实例类型" },
"app.onboarding.instance-mode.description": { "message": "StarLight 实例自动安装管理员指定的整合包和版本,每次启动先检查并完成更新,需要登录 StarLight 账号。本地实例可自选版本和整合包,适合第三方服务器与单人游玩。" },
"app.instance-mode.title": { "message": "实例类型" },
@ -5127,6 +5152,9 @@
"app.lab.skin-editor.retry": {
"message": "重试"
},
"app.lab.skin-editor.resource-error": {
"message": "皮肤编辑器文件缺失或损坏,请重新安装启动器,或完整解压便携版。"
},
"app.lab.skin-editor.title": {
"message": "皮肤编辑器"
},
@ -5947,7 +5975,7 @@
"message": "切换主页样式"
},
"app.onboarding.instance-actions.description": {
"message": "从实例页头部启动、停止、修复、配置、导出或打开这个实例。"
"message": "从这里启动或配置实例。首次启动选择玩家后会自动记住,之后可在实例设置中切换。"
},
"app.onboarding.instance-actions.title": {
"message": "主操作台"
@ -6870,6 +6898,12 @@
"app.settings.defaults.environment-variables-placeholder": {
"message": "输入环境变量……"
},
"app.settings.defaults.force-unicode-font": {
"message": "强制使用 Unicode 字体"
},
"app.settings.defaults.force-unicode-font-description": {
"message": "初始化新实例时使用 Unicode 字体。默认关闭;实例已有的字体设置会保留。"
},
"app.settings.defaults.fullscreen": {
"message": "全屏"
},
@ -7792,7 +7826,7 @@
"message": "小鬼当家"
},
"app.skins.skin-site-account.description": {
"message": "在下方选择皮肤后,可直接应用到 {player}。切换启动器页面时,皮肤站登录状态也会保持。"
"message": "在下方选择皮肤后,可直接应用到 {player}。"
},
"app.skins.skin-site-account.empty-description": {
"message": "这个皮肤站账号暂时没有可用的玩家角色。"

View File

@ -12,6 +12,14 @@
"app.hosted-packs.auto-installing": { "message": "正在下載並安裝伺服器整合包…" },
"app.hosted-packs.auto-description": { "message": "從StarLight伺服器取得整合包並自動安裝可一鍵遊玩StarLight伺服器" },
"app.hosted-packs.auto-install": { "message": "安裝 StarLight 官方整合包" },
"app.hosted-install.game-dir.header": { "message": "選擇遊戲目錄" },
"app.hosted-install.game-dir.description": { "message": "StarLight 實例的遊戲資料mods、存檔、設定、資源包會存放在外部遊戲目錄中。請選擇一個根目錄整合包會在其中另外建立一個子資料夾。" },
"app.hosted-install.game-dir.label": { "message": "遊戲目錄根路徑" },
"app.hosted-install.game-dir.browse": { "message": "瀏覽" },
"app.hosted-install.game-dir.no-selection": { "message": "尚未選擇資料夾" },
"app.hosted-install.game-dir.reset-default": { "message": "使用預設位置" },
"app.hosted-install.game-dir.preview": { "message": "遊戲檔案將安裝到:{path}" },
"app.hosted-install.game-dir.confirm": { "message": "安裝" },
"app.onboarding.instance-mode.title": { "message": "選擇實例類型" },
"app.onboarding.instance-mode.description": { "message": "StarLight 實例自動安裝管理員指定的整合包和版本,每次啟動先檢查並完成更新,需要登入 StarLight 帳號。本地實例可自行選擇版本和整合包,適合第三方伺服器與單人遊玩。" },
"app.instance-mode.title": { "message": "實例類型" },
@ -6567,6 +6575,12 @@
"app.settings.defaults.environment-variables-placeholder": {
"message": "輸入環境變數..."
},
"app.settings.defaults.force-unicode-font": {
"message": "強制使用 Unicode 字型"
},
"app.settings.defaults.force-unicode-font-description": {
"message": "初始化新例項時使用 Unicode 字型。預設關閉;例項既有的字型設定會保留。"
},
"app.settings.defaults.fullscreen": {
"message": "全螢幕"
},
@ -7483,7 +7497,7 @@
"message": "小鬼當家"
},
"app.skins.skin-site-account.description": {
"message": "在下方選擇皮膚後,可直接套用到 {player}。切換啟動器頁面時,皮膚站登入狀態也會保持。"
"message": "在下方選擇皮膚後,可直接套用到 {player}。"
},
"app.skins.skin-site-account.empty-description": {
"message": "這個皮膚站帳號暫時沒有可用的玩家角色。"

View File

@ -4,6 +4,7 @@ import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
import { inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import HostedGameDirModal from '@/components/instance/HostedGameDirModal.vue'
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
import type { InstanceMode } from '@/helpers/hosted-packs'
import { useHostedCreation } from '@/composables/useHostedCreation'
@ -15,6 +16,18 @@ const { installing, installError, createdInstance, completed, acknowledge, insta
const instanceMode = ref<InstanceMode>(
installing.value || createdInstance.value ? 'starlight' : 'local',
)
const hostedGameDirModal = ref<InstanceType<typeof HostedGameDirModal>>()
// One-click StarLight installs reuse the same external game-directory choice
// as the custom creation flow: the pack gets its own folder under `<root>`.
function promptHostedGameDir() {
hostedGameDirModal.value?.show()
}
async function installHostedWithGameDir(gameDirRoot: string) {
await install(gameDirRoot)
}
async function openCompleted(instanceId: string) {
try {
const failure = await router.push(`/instance/${encodeURIComponent(instanceId)}/`)
@ -103,7 +116,12 @@ async function handleStartFresh() {
await openCompleted(createdInstance.value)
return
}
await install()
if (createdInstance.value) {
// A previous attempt already created the instance; retry in place.
await install()
return
}
promptHostedGameDir()
return
}
showModal?.({
@ -179,6 +197,10 @@ function handleImportExisting() {
/>
</div>
<HostedGameDirModal
ref="hostedGameDirModal"
@confirm="installHostedWithGameDir"
/>
<HostedPackProgress :instance-id="createdInstance" :active="installing" />
<p v-if="installError" class="m-0 text-red" role="alert">{{ installError }}</p>
<p v-if="instanceMode === 'local'" class="m-0 text-sm text-secondary">

View File

@ -99,7 +99,7 @@
/>
<RouterLink
v-else
:to="`/instance/${encodeURIComponent(bar.bar_type?.instance_id ?? '')}/mods`"
:to="hostedRetryRoute(bar.bar_type?.instance_id ?? '')"
class="mt-3 inline-block text-brand hover:underline"
>
{{ formatMessage(messages.hostedRetry) }}
@ -471,6 +471,7 @@ import { useRoute, useRouter } from 'vue-router'
import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue'
import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge'
import type { CurseForgeManualDownloadItem } from '@/helpers/curseforge-manual'
import { hostedRetryRoute } from '@/helpers/hosted-install-retry'
import {
download_job_support_details,
type InstallJobSnapshot,

View File

@ -6,6 +6,7 @@ import {
LoadingIndicator,
useVIntl,
} from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { save } from '@tauri-apps/plugin-dialog'
import { writeFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os'
@ -27,6 +28,11 @@ const messages = defineMessages({
id: 'app.lab.skin-editor.load-error-description',
defaultMessage: 'The embedded editor did not finish loading. Try again.',
},
resourceError: {
id: 'app.lab.skin-editor.resource-error',
defaultMessage:
'Skin editor files are missing or damaged. Reinstall the launcher or extract the complete portable archive.',
},
retry: { id: 'app.lab.skin-editor.retry', defaultMessage: 'Try again' },
exportSkin: { id: 'app.lab.skin-editor.export-skin', defaultMessage: 'Minecraft skin PNG' },
})
@ -40,7 +46,10 @@ const blockbenchLocale = computed(() => {
})
const editorState = ref<'loading' | 'ready' | 'error'>('loading')
const errorDetail = ref('')
const resourceError = ref(false)
const frameKey = ref(0)
let loadAttempt = 0
let loadTimeout: number | undefined
const editorUrl = computed(() => {
@ -60,6 +69,8 @@ function clearLoadTimeout() {
function beginEditorLoad() {
clearLoadTimeout()
errorDetail.value = ''
resourceError.value = false
editorState.value = 'loading'
loadTimeout = window.setTimeout(() => {
editorState.value = 'error'
@ -77,11 +88,22 @@ function markEditorError() {
}
async function reloadEditor() {
const attempt = ++loadAttempt
beginEditorLoad()
if (!editorUrl.value) {
if (!import.meta.env.DEV) {
platformName.value = undefined
try {
platformName.value = await platform()
const errors = await invoke<string[]>('get_skin_editor_resource_errors')
if (attempt !== loadAttempt) return
if (errors.length) {
resourceError.value = true
errorDetail.value = errors.join(', ')
markEditorError()
return
}
platformName.value = platform()
} catch (error) {
if (attempt !== loadAttempt) return
markEditorError()
handleError(error)
return
@ -104,7 +126,18 @@ function handleFrameLoad() {
async function handleEditorMessage(event: MessageEvent<unknown>) {
if (event.source !== frame.value?.contentWindow) return
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: unknown; name?: unknown; dataUrl?: unknown }
const message = event.data as {
type?: unknown
name?: unknown
dataUrl?: unknown
error?: unknown
}
if (message.type === 'axolotl-skin-load-error' && typeof message.error === 'string') {
if (editorState.value === 'ready') return
errorDetail.value = message.error.slice(0, 1000)
markEditorError()
return
}
if (message.type === 'axolotl-skin-theme-ready') {
sendThemeToEditor()
markEditorReady()
@ -149,15 +182,11 @@ onMounted(async () => {
attributeFilter: ['class', 'style'],
})
if (!import.meta.env.DEV) {
try {
platformName.value = await platform()
} catch (error) {
markEditorError()
handleError(error)
}
await reloadEditor()
}
})
onUnmounted(() => {
loadAttempt += 1
clearLoadTimeout()
window.removeEventListener('message', handleEditorMessage)
themeObserver?.disconnect()
@ -184,7 +213,12 @@ onUnmounted(() => {
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.loadErrorTitle) }}
</h2>
<p class="m-0 max-w-md text-secondary">{{ formatMessage(messages.loadErrorDescription) }}</p>
<p class="m-0 max-w-md text-secondary">
{{ formatMessage(resourceError ? messages.resourceError : messages.loadErrorDescription) }}
</p>
<p v-if="errorDetail" class="m-0 max-w-xl break-words text-sm text-secondary">
{{ errorDetail }}
</p>
<ButtonStyled color="brand" @click="reloadEditor">
{{ formatMessage(messages.retry) }}
</ButtonStyled>
@ -195,8 +229,8 @@ onUnmounted(() => {
ref="frame"
:title="formatMessage(messages.title)"
:src="editorUrl"
class="h-full min-h-0 w-full flex-1 border-0 transition-opacity duration-150"
:class="editorState === 'ready' ? 'opacity-100' : 'pointer-events-none opacity-0'"
class="h-full min-h-0 w-full flex-1 border-0"
:class="{ 'pointer-events-none': editorState !== 'ready' }"
:aria-label="formatMessage(messages.title)"
:aria-hidden="editorState !== 'ready'"
@load="handleFrameLoad"

View File

@ -231,8 +231,7 @@ const messages = defineMessages({
},
skinSiteManagementDescription: {
id: 'app.skins.skin-site-account.description',
defaultMessage:
'Select a skin below and apply it directly to {player}. The skin site session remains available while you move between launcher pages.',
defaultMessage: 'Select a skin below and apply it directly to {player}.',
},
skinSiteMojangDescription: {
id: 'app.skins.skin-site-account.mojang-description',

View File

@ -10,7 +10,7 @@ import {
import { onUnmounted, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons'
import instanceThinkingImage from '@/assets/illustrations/instance-thinking.png'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
import { instance_listener } from '@/helpers/events.js'
@ -73,11 +73,15 @@ onUnmounted(() => {
<template v-if="instances && instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
<div v-else class="no-instance flex flex-col items-center justify-center h-full gap-3">
<div class="icon">
<NewInstanceImage />
</div>
<h3>{{ formatMessage(messages.noInstances) }}</h3>
<div v-else class="flex flex-col items-center justify-center h-full gap-3">
<img
:src="instanceThinkingImage"
alt=""
aria-hidden="true"
class="h-40 w-40 object-contain"
draggable="false"
/>
<h3 class="m-0">{{ formatMessage(messages.noInstances) }}</h3>
<ButtonStyled color="brand">
<button
data-onboarding-id="create-instance"
@ -91,19 +95,3 @@ onUnmounted(() => {
</div>
</div>
</template>
<style lang="scss" scoped>
.no-instance {
p,
h3 {
margin: 0;
}
.icon {
svg {
width: 10rem;
height: 10rem;
}
}
}
</style>

View File

@ -1,16 +1,22 @@
import { createContext } from '@modrinth/ui'
import { computed, type ComputedRef, type Ref, ref } from 'vue'
import {
forgetHostedCreation,
markHostedCreationCompleted,
markHostedCreationFailed,
} from '@/composables/useHostedCreation'
import { runHostedSync } from '@/composables/useHostedSync'
import { setCurseForgeManualDownloads } from '@/helpers/curseforge-manual'
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
import { createHostedDownloadFailures } from '@/helpers/hosted-download-failures'
import { forgetHostedCreation } from '@/composables/useHostedCreation'
import {
download_request_listener,
install_job_listener,
loading_listener,
instance_listener,
loading_listener,
} from '@/helpers/events'
import { createHostedDownloadFailures } from '@/helpers/hosted-download-failures'
import { retryInstallJob } from '@/helpers/hosted-install-retry'
import { getInstanceMode, onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
import {
download_history_clear,
download_job_cancel,
@ -50,6 +56,7 @@ export interface DownloadManager {
refresh: () => Promise<void>
cancel: (jobId: string) => Promise<void>
retry: (jobId: string) => Promise<void>
retryHosted: (instanceId: string, sourceJobId?: string) => Promise<void>
resume: (jobId: string) => Promise<void>
skipMissingContent: (jobId: string) => Promise<void>
remove: (jobId: string) => Promise<void>
@ -376,8 +383,29 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
}
async function retry(jobId: string) {
const job = await download_job_retry(jobId)
await reconcileJob(job)
const original =
jobs.value.find((candidate) => candidate.job_id === jobId) ?? (await download_job_get(jobId))
const job = await retryInstallJob(original, {
getInstanceMode,
retryHosted,
retryGeneric: download_job_retry,
})
if (job) await reconcileJob(job)
}
async function retryHosted(instanceId: string, sourceJobId?: string) {
try {
await runHostedSync(instanceId)
} catch (error) {
markHostedCreationFailed(instanceId, error)
throw error
}
markHostedCreationCompleted(instanceId)
if (sourceJobId) {
await download_job_delete(sourceJobId).catch(handleError)
jobs.value = jobs.value.filter((job) => job.job_id !== sourceJobId)
}
await refresh()
}
async function resume(jobId: string) {
@ -459,6 +487,7 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
refresh,
cancel,
retry,
retryHosted,
resume,
skipMissingContent,
remove,

View File

@ -92,7 +92,6 @@ features = [
"Foundation",
"UI_ViewManagement",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_System_Com",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",

View File

@ -111,6 +111,9 @@ fn main() {
"poll_device_login",
"begin_yggdrasil_login",
"finish_yggdrasil_login",
"login_skin_site_player",
"get_instance_player",
"set_instance_player",
"list_yggdrasil_saved_logins",
"get_yggdrasil_password",
"set_yggdrasil_password",

View File

@ -59,7 +59,7 @@
"url": "https://api.purpurmc.org/*"
},
{
"url": "https://update.axlmc.org/*"
"url": "https://skin.starlight.cool/*"
},
{
"url": "http://localhost:8000/*"

View File

@ -31,6 +31,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
get_default_user,
set_default_user,
get_users,
get_instance_player,
set_instance_player,
login_skin_site_player,
])
.build()
}
@ -318,6 +321,33 @@ pub async fn finish_yggdrasil_login(
const YGGDRASIL_SAVED_LOGINS_KEY: &str = "yggdrasil-saved-logins";
#[tauri::command]
pub async fn get_instance_player(
instance_id: String,
) -> Result<Option<minecraft_auth::InstancePlayer>> {
Ok(minecraft_auth::get_instance_player(&instance_id).await?)
}
#[tauri::command]
pub async fn set_instance_player(
instance_id: String,
player: minecraft_auth::InstancePlayer,
) -> Result<()> {
Ok(minecraft_auth::set_instance_player(&instance_id, player).await?)
}
#[tauri::command]
pub async fn login_skin_site_player(
token: String,
player_id: uuid::Uuid,
user_id: String,
) -> Result<Credentials> {
Ok(
minecraft_auth::login_skin_site_player(&token, player_id, &user_id)
.await?,
)
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SavedYggdrasilLogin {
pub api_root: String,
@ -443,22 +473,24 @@ fn yggdrasil_saved_logins_entry() -> Result<keyring::Entry> {
fn read_yggdrasil_saved_logins() -> Result<Vec<SavedYggdrasilLogin>> {
match yggdrasil_saved_logins_entry()?.get_password() {
Ok(saved_logins) => match serde_json::from_str::<Vec<SavedYggdrasilLogin>>(
&saved_logins,
) {
Ok(saved_logins) => Ok(saved_logins
.into_iter()
.filter(|saved_login| {
saved_login.api_root == STARLIGHT_YGGDRASIL_API_ROOT
})
.collect()),
Err(error) => {
tracing::warn!(
"Ignoring an invalid saved Yggdrasil login index: {error}"
);
Ok(Vec::new())
Ok(saved_logins) => {
match serde_json::from_str::<Vec<SavedYggdrasilLogin>>(
&saved_logins,
) {
Ok(saved_logins) => Ok(saved_logins
.into_iter()
.filter(|saved_login| {
saved_login.api_root == STARLIGHT_YGGDRASIL_API_ROOT
})
.collect()),
Err(error) => {
tracing::warn!(
"Ignoring an invalid saved Yggdrasil login index: {error}"
);
Ok(Vec::new())
}
}
},
}
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
Err(error) => Err(keyring_error(error)),
}

View File

@ -66,8 +66,10 @@ pub async fn hosted_default() -> Result<theseus::pack::hosted::Publication> {
}
#[tauri::command]
pub async fn hosted_create() -> Result<String> {
Ok(theseus::pack::hosted::create().await?)
pub async fn hosted_create(
game_dir_root: Option<String>,
) -> Result<String> {
Ok(theseus::pack::hosted::create(game_dir_root).await?)
}
#[tauri::command]

View File

@ -462,6 +462,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
})
.transpose()?,
launch_overrides: Some(InstanceLaunchOverridesPatch {
player: None,
instance_mode: None,
java_path: edit_instance.java_path,
extra_launch_args: edit_instance.extra_launch_args,

View File

@ -249,32 +249,33 @@ impl LightweightMode {
}
None if payload.event == "launched" => {
let app = app.clone();
// 最大化游戏窗口会轮询等待 MC 窗口出现(最多数秒)。它必须
// 与过渡动画并行,否则遮罩要等它跑完才出现,用户会看到一段
// 毫无反馈的空白期。
if payload.maximize_window {
let pid = payload.pid;
tauri::async_runtime::spawn(async move {
maximize_minecraft_window(pid).await;
});
}
tauri::async_runtime::spawn(async move {
// 窗口过渡:把启动器全屏遮罩 → 缩放 → 淡出 → 最小化
crate::mc_transition::run(
&app,
payload.pid,
payload.maximize_window,
)
.await;
// 过渡动画结束后,统一进入轻量模式(隐藏到托盘)。
// 由过渡流程接管,不再各自判断用户的「轻量模式 /
// 启动后隐藏」设置,避免与动画收尾冲突,也顺带修掉
// 「重开时窗口全透明」的问题(轻量模式恢复时是重建
// 全新窗口,不残留旧的 opacity
let state = app.state::<LightweightMode>();
if let Err(error) = state.enter(&app) {
if payload.maximize_window {
maximize_minecraft_window(payload.pid).await;
}
let settings = match theseus::settings::get().await {
Ok(settings) => settings,
Err(error) => {
tracing::warn!(
"Failed to read lightweight mode setting: {error}"
);
return;
}
};
if settings.enter_lightweight_mode_on_game_launch {
let state = app.state::<LightweightMode>();
if let Err(error) = state.enter(&app) {
tracing::warn!(
"Failed to enter lightweight mode: {error}"
);
}
} else if settings.hide_on_process_start
&& let Some(window) =
app.get_webview_window(MAIN_WINDOW_LABEL)
&& let Err(error) = window.minimize()
{
tracing::warn!(
"Failed to enter lightweight mode after transition: {error}"
"Failed to minimize launcher after Minecraft started: {error}"
);
}
});
@ -353,7 +354,8 @@ unsafe extern "system" fn maximize_if_owned_by_process(
_: windows::Win32::Foundation::LPARAM,
) -> windows::core::BOOL {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE, SetForegroundWindow, ShowWindow,
GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE,
SetForegroundWindow, ShowWindow,
};
use windows::core::BOOL;

View File

@ -22,7 +22,6 @@ use theseus::prelude::*;
mod api;
mod error;
mod lightweight_mode;
mod mc_transition;
mod mod_translation;
mod portable;
mod seed_map;
@ -78,9 +77,10 @@ fn blockbench_skin_response(
} else {
relative_path.to_path_buf()
});
let contents = match fs::read(file_path) {
let contents = match fs::read(&file_path) {
Ok(contents) => contents,
Err(_) => {
Err(error) => {
tracing::warn!(path = %file_path.display(), %error, "Skin editor resource could not be read");
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Vec::new())
@ -89,10 +89,10 @@ fn blockbench_skin_response(
};
let contents = if is_compressed_bundle {
let mut decompressed = Vec::new();
if flate2::read::GzDecoder::new(contents.as_slice())
if let Err(error) = flate2::read::GzDecoder::new(contents.as_slice())
.read_to_end(&mut decompressed)
.is_err()
{
tracing::warn!(path = %file_path.display(), %error, "Skin editor bundle could not be decoded");
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Vec::new())
@ -126,6 +126,91 @@ fn blockbench_skin_response(
.expect("failed to build Blockbench skin response")
}
fn skin_editor_resource_errors(resource_dir: &Path) -> Vec<String> {
["index.html", "css/setup.css", "dist/skin.bundle.js"]
.into_iter()
.filter(|path| {
let response = blockbench_skin_response(path, resource_dir);
!response.status().is_success() || response.body().is_empty()
})
.map(str::to_owned)
.collect()
}
#[cfg(test)]
mod skin_editor_tests {
use super::*;
use std::io::Write;
fn editor_resources() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("css")).unwrap();
fs::create_dir(directory.path().join("dist")).unwrap();
fs::write(directory.path().join("index.html"), "<!doctype html>")
.unwrap();
fs::write(directory.path().join("css/setup.css"), "body {}").unwrap();
let mut bundle = flate2::write::GzEncoder::new(
Vec::new(),
flate2::Compression::default(),
);
bundle.write_all(b"window.editor = true;").unwrap();
fs::write(
directory.path().join("dist/skin.bundle.js.gz"),
bundle.finish().unwrap(),
)
.unwrap();
directory
}
#[test]
fn packaged_skin_editor_bundle_is_readable() {
let directory = editor_resources();
assert!(skin_editor_resource_errors(directory.path()).is_empty());
let response =
blockbench_skin_response("/dist/skin.bundle.js", directory.path());
assert_eq!(response.body(), b"window.editor = true;");
assert_eq!(
response.headers()[header::CONTENT_TYPE],
"text/javascript; charset=utf-8"
);
}
#[test]
fn missing_skin_editor_resources_are_reported() {
let directory = tempfile::tempdir().unwrap();
assert_eq!(
skin_editor_resource_errors(directory.path()),
["index.html", "css/setup.css", "dist/skin.bundle.js"]
);
}
#[test]
fn corrupt_skin_editor_bundle_is_reported() {
let directory = editor_resources();
fs::write(
directory.path().join("dist/skin.bundle.js.gz"),
"invalid gzip",
)
.unwrap();
assert_eq!(
skin_editor_resource_errors(directory.path()),
["dist/skin.bundle.js"]
);
}
}
#[tauri::command]
fn get_skin_editor_resource_errors(
app: tauri::AppHandle,
) -> Result<Vec<String>, String> {
let resource_dir = app
.path()
.resource_dir()
.map_err(|error| error.to_string())?
.join(BLOCKBENCH_SKIN_RESOURCE_DIR);
Ok(skin_editor_resource_errors(&resource_dir))
}
fn is_allowed_blockbench_skin_request(
request: &tauri::http::Request<Vec<u8>>,
) -> bool {
@ -233,6 +318,21 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
Ok(())
}
/// Directory that contains the launcher executable. Used as the default
/// external game-directory root for the one-click StarLight install flow.
#[tauri::command]
fn get_launcher_root_dir() -> api::Result<String> {
let exe = std::env::current_exe().map_err(|error| {
theseus::Error::from(theseus::ErrorKind::FSError(error.to_string()))
})?;
let dir = exe.parent().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::FSError(
"Launcher executable has no parent directory".to_string(),
))
})?;
Ok(dir.to_string_lossy().into_owned())
}
#[tauri::command]
fn get_update_channel(app: tauri::AppHandle) -> api::Result<String> {
let channel = read_update_channel_state(&app)?
@ -671,6 +771,10 @@ fn main() {
"axolotl-skin",
move |context, request| {
if !is_allowed_blockbench_skin_request(&request) {
tracing::warn!(
path = request.uri().path(),
"Skin editor resource request was rejected"
);
return Response::builder()
.status(StatusCode::FORBIDDEN)
.body(Vec::new())
@ -689,6 +793,13 @@ fn main() {
);
builder = builder
.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("skin-editor-errors")
.js_init_script_on_all_frames(include_str!(
"skin_editor_bridge.js"
))
.build(),
)
.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("skin-site-session")
.js_init_script_on_all_frames(include_str!(
@ -839,6 +950,8 @@ fn main() {
.manage(PendingUpdateData::default())
.invoke_handler(tauri::generate_handler![
initialize_state,
get_skin_editor_resource_errors,
get_launcher_root_dir,
get_update_channel,
get_current_app_database_path,
set_update_channel,
@ -868,10 +981,6 @@ fn main() {
lightweight_mode::lightweight_mode_frontend_ready,
lightweight_mode::lightweight_mode_set_route,
lightweight_mode::lightweight_mode_enter,
mc_transition::mc_transition_fade_done,
mc_transition::mc_transition_cover_ready,
mc_transition::mc_transition_logo_in_done,
mc_transition::mc_transition_logo_out_done,
]);
tracing::info!("Initializing app...");

View File

@ -1,453 +0,0 @@
//! Minecraft 启动时的窗口过渡动画(仅 Windows 生效)。
//!
//! 流程(四阶段):
//! 阶段A启动器放大到全屏0.2s 动画),全程置顶 + 聚焦;
//! 阶段B等待 MC 窗口出现(最多 60s期间启动器盖住游戏
//! 阶段C获取 MC 窗口位置/大小1s 动画缩放到相同大小;
//! 阶段D大小一致后0.5s 淡出(不与缩放同步);
//! 阶段E聚焦游戏窗口 → 启动器最小化。
//!
//! 过渡结束后返回,由调用方继续执行用户的轻量模式 / 隐藏设置。
//!
//! 注意HWND 是裸指针,非 Send不能跨 await 持有,因此一律用 `usize`
//! 保存窗口句柄,只在同步的 win32 调用里临时转回 HWND。
#![cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports))]
use tauri::{AppHandle, Emitter, Manager, WebviewWindow};
/// 主窗口 label与 lightweight_mode.rs 保持一致。
pub const MAIN_WINDOW_LABEL: &str = "main";
/// 等待 MC 窗口出现的轮询次数与间隔120 × 500ms = 60s
const FIND_RETRIES: u32 = 120;
const FIND_INTERVAL_MS: u64 = 500;
/// 缩放动画总时长与帧数200ms / 25 帧 = 8ms 一帧)。
const ANIM_DURATION_MS: u64 = 200;
const ANIM_STEPS: u32 = 25;
/// 等待前端完成 CSS 淡出的超时兜底(毫秒)。淡出 500ms留足余量。
const FADE_TIMEOUT_MS: u64 = 700;
#[cfg(target_os = "windows")]
static FADE_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
static COVER_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
static LOGO_IN_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
static LOGO_OUT_DONE: std::sync::Mutex<
Option<tokio::sync::oneshot::Sender<()>>,
> = std::sync::Mutex::new(None);
/// 前端完成淡出动画后的回调命令。
#[tauri::command]
pub fn mc_transition_fade_done() {
#[cfg(target_os = "windows")]
{
if let Ok(mut guard) = FADE_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 前端覆盖层渐显完成后的回调命令。
#[tauri::command]
pub fn mc_transition_cover_ready() {
#[cfg(target_os = "windows")]
{
if let Ok(mut guard) = COVER_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 前端 SVG logo 渐显完成后的回调命令。
#[tauri::command]
pub fn mc_transition_logo_in_done() {
#[cfg(target_os = "windows")]
{
if let Ok(mut guard) = LOGO_IN_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 前端 SVG logo 渐隐 + 背景变红完成后的回调命令。
#[tauri::command]
pub fn mc_transition_logo_out_done() {
#[cfg(target_os = "windows")]
{
if let Ok(mut guard) = LOGO_OUT_DONE.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
}
}
/// 缓入缓出曲线,比纯 ease-out 更柔和自然。
#[cfg(target_os = "windows")]
fn ease_in_out(t: f64) -> f64 {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
/// 分帧移动 + 缩放启动器窗口。`from`/`to` 为 (x, y, w, h)。
/// 若起止相同则直接返回;结束时补一帧精确坐标,避免累积误差。
#[cfg(target_os = "windows")]
async fn animate_resize(
_window: &WebviewWindow,
hwnd: usize,
from: (i32, i32, u32, u32),
to: (i32, i32, u32, u32),
duration_ms: u64,
) {
if from == to {
return;
}
let step_ms = (duration_ms / ANIM_STEPS as u64).max(1);
for step in 1..=ANIM_STEPS {
let t = step as f64 / ANIM_STEPS as f64;
let e = ease_in_out(t);
let x = from.0 as f64 + (to.0 - from.0) as f64 * e;
let y = from.1 as f64 + (to.1 - from.1) as f64 * e;
let w = from.2 as f64 + (to.2 as i64 - from.2 as i64) as f64 * e;
let h = from.3 as f64 + (to.3 as i64 - from.3 as i64) as f64 * e;
// 单次 SetWindowPos移动+缩放一把过)比重绘两次的
// set_position/set_size 更顺滑,也避免无边框窗口的
// 不可见 resize border 造成的偏移。
win_impl::set_client_rect(
hwnd,
x as i32,
y as i32,
w.max(1.0) as i32,
h.max(1.0) as i32,
);
tokio::time::sleep(std::time::Duration::from_millis(step_ms)).await;
}
// 补最后一帧精确值(同样只设可见客户区,避免无边框边框造成偏移)
win_impl::set_client_rect(
hwnd,
to.0,
to.1,
to.2.max(1) as i32,
to.3.max(1) as i32,
);
}
pub async fn run(app: &AppHandle, pid: u32, _maximize: bool) {
if pid == 0 {
return;
}
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
#[cfg(target_os = "windows")]
let hwnd_raw = window.hwnd().map(|h| h.0 as usize).unwrap_or(0);
#[cfg(not(target_os = "windows"))]
let hwnd_raw = 0usize;
// 记录启动器当前「可见客户区」作为起点(与最终对齐用的是同一套
// 坐标系,避免无边框窗口边框带来的偏移累积)。
let start = {
#[cfg(target_os = "windows")]
{
win_impl::get_client_rect(hwnd_raw).unwrap_or_else(|| {
let p = window
.outer_position()
.map(|p| (p.x, p.y))
.unwrap_or((0, 0));
let s = window
.outer_size()
.map(|s| (s.width, s.height))
.unwrap_or((1280, 800));
(p.0, p.1, s.0, s.1)
})
}
#[cfg(not(target_os = "windows"))]
{
let p = window
.outer_position()
.map(|p| (p.x, p.y))
.unwrap_or((0, 0));
let s = window
.outer_size()
.map(|s| (s.width, s.height))
.unwrap_or((1280, 800));
(p.0, p.1, s.0, s.1)
}
};
// 全屏目标 = 启动器当前所在显示器的完整矩形
let fullscreen = match window
.current_monitor()
.ok()
.flatten()
.or_else(|| window.primary_monitor().ok().flatten())
{
Some(m) => {
(m.position().x, m.position().y, m.size().width, m.size().height)
}
None => {
return;
}
};
// 窗口可能处于最小化/隐藏(轻量模式遗留),先恢复可见再动画,
// 否则遮罩虽然在 DOM 里建好,用户却看不到。
let _ = window.show();
let _ = window.unminimize();
// 全程置顶 + 聚焦
let _ = window.set_always_on_top(true);
let _ = window.set_focus();
// ---------- 第①步:显示纯色遮罩(无图,主题色) ----------
let (cover_tx, cover_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = COVER_DONE.lock() {
*guard = Some(cover_tx);
}
let _ = app.emit("mc-transition-cover-show", ());
let _ = tokio::time::timeout(
std::time::Duration::from_millis(1000),
cover_rx,
)
.await;
// ---------- 第②步放大到全屏0.2s ----------
animate_resize(&window, hwnd_raw, start, fullscreen, ANIM_DURATION_MS)
.await;
// ---------- 第③步:全屏后,渐显 SVG logo ----------
let (lin_tx, lin_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = LOGO_IN_DONE.lock() {
*guard = Some(lin_tx);
}
let _ = app.emit("mc-transition-logo-in", ());
let _ = tokio::time::timeout(
std::time::Duration::from_millis(1500),
lin_rx,
)
.await;
// ---------- 等待 MC 窗口出现(最多 60s ----------
let mut mc_raw: usize = 0;
for _ in 0..FIND_RETRIES {
if let Some(raw) = win_impl::find_window(pid) {
mc_raw = raw;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(FIND_INTERVAL_MS))
.await;
}
if mc_raw == 0 {
let _ = window.set_always_on_top(false);
animate_resize(&window, hwnd_raw, fullscreen, start, 300).await;
let _ = app.emit("mc-transition-reset-opacity", ());
return;
}
// ---------- 第④步:缩小前,背景变 Mojang 红 + 渐隐 logo ----------
let (lout_tx, lout_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = LOGO_OUT_DONE.lock() {
*guard = Some(lout_tx);
}
let _ = app.emit("mc-transition-logo-out", ());
let _ = tokio::time::timeout(
std::time::Duration::from_millis(1500),
lout_rx,
)
.await;
// ---------- 第⑤步:缩小到 MC 窗口矩形1s ----------
let mc_rect = win_impl::get_rect(mc_raw).unwrap_or((
fullscreen.0,
fullscreen.1,
fullscreen.2 as i32,
fullscreen.3 as i32,
));
let target = (
mc_rect.0,
mc_rect.1,
mc_rect.2.max(1) as u32,
mc_rect.3.max(1) as u32,
);
let _ = window.set_always_on_top(true);
let _ = window.set_focus();
animate_resize(&window, hwnd_raw, fullscreen, target, ANIM_DURATION_MS)
.await;
// ---------- 第⑥步:等待 1s游戏稳定遮罩仍在 ----------
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
// ---------- 第⑦步淡出0.5s,含遮罩一起) ----------
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
if let Ok(mut guard) = FADE_DONE.lock() {
*guard = Some(done_tx);
}
let _ = app.emit("mc-transition-fadeout", ());
let _ = tokio::time::timeout(
std::time::Duration::from_millis(FADE_TIMEOUT_MS),
done_rx,
)
.await;
// ---------- 第⑧步:聚焦游戏 + 最小化启动器 ----------
let _ = window.set_always_on_top(false);
win_impl::focus(mc_raw);
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
let _ = window.minimize();
let _ = app.emit("mc-transition-reset-opacity", ());
}
#[cfg(not(target_os = "windows"))]
pub async fn run(_app: &AppHandle, _pid: u32, _maximize: bool) {}
#[cfg(target_os = "windows")]
mod win_impl {
use std::sync::{
Mutex,
atomic::{AtomicU32, AtomicUsize, Ordering},
};
use windows::Win32::Foundation::{HWND, LPARAM, POINT, RECT};
use windows::Win32::Graphics::Gdi::ClientToScreen;
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetClientRect, GetWindowRect, GetWindowThreadProcessId,
IsWindowVisible, SetForegroundWindow,
};
use windows::core::BOOL;
/// 目标进程 pid枚举回调通过静态变量传递
static TARGET_PID: AtomicU32 = AtomicU32::new(0);
/// 命中的窗口句柄,以 usize 存储HWND 非 Send/Sync
static FOUND_HWND: AtomicUsize = AtomicUsize::new(0);
/// 串行化枚举,避免并发查找互相覆盖静态变量。
static ENUM_LOCK: Mutex<()> = Mutex::new(());
/// usize → HWND。调用方保证数值来自真实句柄。
fn to_hwnd(raw: usize) -> HWND {
HWND(raw as *mut core::ffi::c_void)
}
unsafe extern "system" fn find_cb(hwnd: HWND, _: LPARAM) -> BOOL {
let mut pid = 0u32;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) };
if pid != TARGET_PID.load(Ordering::Relaxed)
|| !unsafe { IsWindowVisible(hwnd).as_bool() }
{
return BOOL(1);
}
let mut rect = RECT::default();
if unsafe { GetWindowRect(hwnd, &mut rect) }.is_ok() {
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
// 过滤掉工具窗口 / 0 尺寸窗口,只认真正的游戏主窗口
if w > 50 && h > 50 {
FOUND_HWND.store(hwnd.0 as usize, Ordering::Relaxed);
return BOOL(0); // 找到即停止枚举
}
}
BOOL(1)
}
/// 查找属于 `pid` 的可见顶层窗口返回句柄原始值0 表示未找到)。
pub fn find_window(pid: u32) -> Option<usize> {
let _guard = ENUM_LOCK.lock().ok()?;
TARGET_PID.store(pid, Ordering::Relaxed);
FOUND_HWND.store(0, Ordering::Relaxed);
unsafe {
let _ = EnumWindows(Some(find_cb), LPARAM(0));
}
let raw = FOUND_HWND.load(Ordering::Relaxed);
(raw != 0).then_some(raw)
}
/// 取窗口矩形,返回 (x, y, width, height)。
pub fn get_rect(raw: usize) -> Option<(i32, i32, i32, i32)> {
let mut r = RECT::default();
unsafe { GetWindowRect(to_hwnd(raw), &mut r).ok()? };
Some((r.left, r.top, r.right - r.left, r.bottom - r.top))
}
/// 取窗口「可见客户区」在屏幕坐标下的矩形,返回 (x, y, w, h)。
/// 无边框窗口仍带不可见的 resize border客户区比窗口矩形内缩
/// 这正是全屏后左侧留缝、整体偏右的根源。
pub fn get_client_rect(raw: usize) -> Option<(i32, i32, u32, u32)> {
if raw == 0 {
return None;
}
let hwnd = to_hwnd(raw);
let mut cr = RECT::default();
if unsafe { GetClientRect(hwnd, &mut cr) }.is_err() {
return None;
}
let mut origin = POINT { x: 0, y: 0 };
if !unsafe { ClientToScreen(hwnd, &mut origin) }.as_bool() {
return None;
}
Some((
origin.x,
origin.y,
(cr.right - cr.left) as u32,
(cr.bottom - cr.top) as u32,
))
}
/// 让「可见客户区」精确落在屏幕坐标 (x, y, w, h),自动补偿无边框
/// 窗口的不可见边框。动画与对齐统一用它,避免左/上缝隙。
pub fn set_client_rect(raw: usize, x: i32, y: i32, w: i32, h: i32) {
use windows::Win32::UI::WindowsAndMessaging::{
SWP_NOACTIVATE, SWP_NOZORDER, SetWindowPos,
};
if raw == 0 {
return;
}
let hwnd = to_hwnd(raw);
let mut wr = RECT::default();
let mut cr = RECT::default();
let mut origin = POINT { x: 0, y: 0 };
unsafe {
let _ = GetWindowRect(hwnd, &mut wr);
let _ = GetClientRect(hwnd, &mut cr);
let _ = ClientToScreen(hwnd, &mut origin);
}
let border_w = (wr.right - wr.left) - (cr.right - cr.left);
let border_h = (wr.bottom - wr.top) - (cr.bottom - cr.top);
let inset_x = origin.x - wr.left;
let inset_y = origin.y - wr.top;
unsafe {
let _ = SetWindowPos(
hwnd,
None,
x - inset_x,
y - inset_y,
w + border_w,
h + border_h,
SWP_NOZORDER | SWP_NOACTIVATE,
);
}
}
/// 把前台焦点交给游戏窗口。
pub fn focus(raw: usize) {
unsafe {
let _ = SetForegroundWindow(to_hwnd(raw));
}
}
}

View File

@ -0,0 +1,32 @@
;(() => {
const isEditor =
location.origin === 'http://axolotl-skin.localhost' ||
(location.protocol === 'axolotl-skin:' && location.host === 'localhost') ||
(location.origin === 'http://localhost:5201' &&
location.pathname === '/__blockbench_skin__/index.html')
if (
!isEditor ||
window.parent === window ||
new URLSearchParams(location.search).get('embed') !== 'skin'
)
return
function report(error) {
window.parent.postMessage(
{ type: 'axolotl-skin-load-error', error: String(error).slice(0, 1000) },
'*',
)
}
window.addEventListener('error', (event) => {
if (event.message) {
if (event.message.startsWith('ResizeObserver loop')) return
report(event.message)
}
})
window.addEventListener('unhandledrejection', (event) => {
report(event.reason?.message || event.reason)
})
window.addEventListener('DOMContentLoaded', () => {
window.blockbenchBundleReady?.catch((error) => report(error?.message || error))
})
})()

View File

@ -3,21 +3,23 @@ use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
use tauri::http::HeaderValue;
use tauri::http::header::ACCEPT;
use tauri::http::HeaderValue;
use tauri::{Manager, ResourceId, Runtime, Webview};
use tauri_plugin_http::reqwest;
use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::{Error, Update, UpdaterExt};
use theseus::{
LoadingBarType, emit_loading, init_loading, launcher_user_agent,
emit_loading, init_loading, launcher_user_agent, LoadingBarType,
};
use tokio::time::Instant;
use url::Url;
const UPDATE_SERVER_LATEST_URL: &str = "https://update.axlmc.org/latest";
const UPDATE_SERVER_API: &str = "https://update.axlmc.org/api/versions";
const UPDATE_SERVER_BASE: &str = "https://update.axlmc.org/";
const STARLIGHT_UPDATE_LATEST_URL: &str =
"https://skin.starlight.cool/starlight/launcher/latest";
const STARLIGHT_UPDATE_VERSIONS_URL: &str =
"https://skin.starlight.cool/starlight/launcher/versions";
const STARLIGHT_UPDATE_BASE_URL: &str = "https://skin.starlight.cool/";
// The updater plugin builds `Update` with no request timeout, so a stalled
// connection would hang the download forever. Bound the whole download.
@ -62,7 +64,10 @@ struct ArtifactEntry {
variant: Option<String>,
platform: String,
architecture: String,
relative_path: String,
#[serde(default)]
relative_path: Option<String>,
#[serde(default)]
download_url: Option<String>,
#[serde(default)]
sha256: Option<String>,
#[serde(default)]
@ -94,7 +99,7 @@ async fn fetch_apt_deb_asset(version: &str) -> Result<AptDebAsset> {
.user_agent(launcher_user_agent())
.timeout(UPDATE_DOWNLOAD_TIMEOUT)
.build()?
.get(UPDATE_SERVER_API)
.get(STARLIGHT_UPDATE_VERSIONS_URL)
.send()
.await?;
@ -139,13 +144,7 @@ async fn fetch_apt_deb_asset(version: &str) -> Result<AptDebAsset> {
)))
})?;
let url =
Url::parse(&format!("{UPDATE_SERVER_BASE}{}", artifact.relative_path))
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(
error.to_string(),
))
})?;
let url = artifact_download_url(artifact)?;
Ok(AptDebAsset {
url,
@ -154,6 +153,31 @@ async fn fetch_apt_deb_asset(version: &str) -> Result<AptDebAsset> {
})
}
fn artifact_download_url(artifact: &ArtifactEntry) -> Result<Url> {
if let Some(download_url) = artifact.download_url.as_deref() {
return Url::parse(download_url).map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(
error.to_string(),
))
.into()
});
}
let relative_path = artifact.relative_path.as_deref().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(
"Update catalog artifact has no download URL".to_string(),
))
})?;
Url::parse(STARLIGHT_UPDATE_BASE_URL)
.and_then(|base| base.join(relative_path))
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(
error.to_string(),
))
.into()
})
}
// ── Updater plugin helpers ───────────────────────────────────────
fn update_channel(channel: &str) -> Result<&str> {
@ -183,7 +207,7 @@ fn update_platform() -> Result<&'static str> {
}
fn update_endpoint() -> Result<Url> {
Url::parse(UPDATE_SERVER_LATEST_URL).map_err(|error| {
Url::parse(STARLIGHT_UPDATE_LATEST_URL).map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(error.to_string()))
.into()
})

View File

@ -13,7 +13,7 @@
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNGNEE1MkMxOTI0MDA4NzYKUldSMkNFQ1N3VkpLUC9kcEtlZFRWak5FRXJsWUw3YllxWGh6bkg3ZEh3K1ZQa1VNZHl6Y0IvQysK",
"endpoints": ["https://update.axlmc.org/latest"],
"endpoints": ["https://skin.starlight.cool/starlight/launcher/latest"],
"windows": {
"installMode": "quiet"
}

View File

@ -111,7 +111,7 @@
"capabilities": ["core", "plugins"],
"devCsp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost http://localhost:5201 ws://localhost:5201 https://modrinth.com https://*.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://fill.papermc.io https://api.papermc.io https://piston-meta.mojang.com https://launchermeta.mojang.com https://meta.fabricmc.net https://files.minecraftforge.net https://maven.minecraftforge.net https://api.purpurmc.org https://mod.mcimirror.top https://mod.tianpao.top https://admin.axlmc.org 'self' data: blob:",
"connect-src": "ipc: http://ipc.localhost http://localhost:5201 ws://localhost:5201 https://modrinth.com https://*.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://fill.papermc.io https://api.papermc.io https://piston-meta.mojang.com https://launchermeta.mojang.com https://meta.fabricmc.net https://files.minecraftforge.net https://maven.minecraftforge.net https://api.purpurmc.org https://mod.mcimirror.top https://mod.tianpao.top https://admin.axlmc.org https://skin.starlight.cool 'self' data: blob:",
"font-src": ["'self'", "data:", "https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
@ -123,7 +123,7 @@
},
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://fill.papermc.io https://api.papermc.io https://piston-meta.mojang.com https://launchermeta.mojang.com https://meta.fabricmc.net https://files.minecraftforge.net https://maven.minecraftforge.net https://api.purpurmc.org https://mod.mcimirror.top https://mod.tianpao.top https://admin.axlmc.org 'self' data: blob:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://fill.papermc.io https://api.papermc.io https://piston-meta.mojang.com https://launchermeta.mojang.com https://meta.fabricmc.net https://files.minecraftforge.net https://maven.minecraftforge.net https://api.purpurmc.org https://mod.mcimirror.top https://mod.tianpao.top https://admin.axlmc.org https://skin.starlight.cool 'self' data: blob:",
"font-src": ["'self'", "data:", "https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",

View File

@ -148,7 +148,6 @@ pub fn run() -> Result<(), String> {
let arguments = parse_arguments()?;
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
let window = WindowBuilder::new()
.with_title("Starlight Launcher")
.with_inner_size(LogicalSize::new(940.0, 620.0))
.with_min_inner_size(LogicalSize::new(940.0, 620.0))

View File

@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN mc_force_unicode_font INTEGER NOT NULL DEFAULT FALSE;

View File

@ -75,24 +75,35 @@ async fn run_with_extra_launch_args_inner(
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
let __t_hosted = std::time::Instant::now();
let _hosted_guard =
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
tracing::info!("[launch-timing] hosted_prepare_launch: {}ms", __t_hosted.elapsed().as_millis());
let state = State::get().await?;
let launch_preparation_timeout =
let context =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.await?;
let launch_preparation_timeout = context
.as_ref()
.and_then(|context| context.launch_overrides.launch_preparation_timeout)
.unwrap_or(DEFAULT_LAUNCH_PREPARATION_TIMEOUT)
.clamp(
MIN_LAUNCH_PREPARATION_TIMEOUT,
MAX_LAUNCH_PREPARATION_TIMEOUT,
);
let default_account = if offline_mode {
let saved_player = context
.as_ref()
.and_then(|context| context.launch_overrides.player.as_ref());
let default_account = if let Some(player) = saved_player {
if offline_mode
&& player.account_type
!= crate::state::MinecraftAccountType::Offline
{
return Err(crate::ErrorKind::InputError("当前实例绑定的是在线玩家,请恢复网络后启动,或在实例设置中手动切换玩家".into()).as_error());
}
Credentials::for_instance_player(player, &state.pool).await?
} else if offline_mode {
Credentials::get_offline_credential(&state.pool)
.await?
.ok_or_else(|| {

View File

@ -7,6 +7,7 @@ use std::time::Duration;
use uuid::Uuid;
use crate::State;
pub use crate::state::InstancePlayer;
pub use crate::state::YggdrasilLoginResult;
use crate::state::{
Credentials, MinecraftAccountType, MinecraftLoginFlow, MinecraftProfile,
@ -172,6 +173,72 @@ pub fn normalize_yggdrasil_api_root(api_root: &str) -> crate::Result<String> {
crate::state::normalize_api_root(api_root)
}
pub async fn get_instance_player(
instance_id: &str,
) -> crate::Result<Option<crate::state::InstancePlayer>> {
let state = State::get().await?;
let context =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".into()).as_error()
})?;
Ok(context.launch_overrides.player)
}
pub async fn set_instance_player(
instance_id: &str,
player: crate::state::InstancePlayer,
) -> crate::Result<()> {
let state = State::get().await?;
let accounts = Credentials::get_all_without_refresh(&state.pool).await?;
let account = accounts.get(&player.id).ok_or_else(|| {
crate::ErrorKind::InputError("所选玩家未登录,请重新登录该账号".into())
.as_error()
})?;
if account.account_type != player.account_type
|| player.skin_site_user.as_ref().is_some_and(|user| {
account.yggdrasil.as_ref().is_none_or(|ygg| {
ygg.login != *user
|| ygg.api_root != "https://skin.starlight.cool/yggdrasil"
})
})
{
return Err(
crate::ErrorKind::InputError("玩家身份不匹配".into()).as_error()
);
}
drop(account);
crate::state::edit_instance(
instance_id,
crate::state::EditInstance {
launch_overrides: Some(
crate::state::InstanceLaunchOverridesPatch {
player: Some(player),
..Default::default()
},
),
..Default::default()
},
&state.pool,
)
.await?;
Ok(())
}
pub async fn login_skin_site_player(
token: &str,
player_id: uuid::Uuid,
user_id: &str,
) -> crate::Result<Credentials> {
let state = State::get().await?;
crate::state::login_skin_site_player(token, player_id, user_id, &state.pool)
.await
}
#[tracing::instrument]
pub async fn get_default_user(
offline_mode: bool,

View File

@ -1,6 +1,7 @@
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
mod progress;
mod tagged;
mod transport;
use crate::{
State,
state::{
@ -32,6 +33,7 @@ use tokio::{
const API: &str = "https://skin.starlight.cool/starlight/mod/packs";
const BINDING: &str = ".starlight-pack.json";
const JOURNAL: &str = ".starlight-pack-pending.json";
const MAX_HOSTED_CONCURRENT_FILES: usize = 8;
static GATES: LazyLock<dashmap::DashMap<String, Arc<Mutex<()>>>> =
LazyLock::new(dashmap::DashMap::new);
static SESSION: Mutex<Option<DownloadSession>> = Mutex::const_new(None);
@ -230,11 +232,42 @@ fn validate(files: &[PackFile]) -> crate::Result<()> {
Ok(())
}
/// Reject symlinks and junctions in every existing ancestor, including internal state files.
#[cfg(windows)]
fn filesystem_path(path: &Path) -> PathBuf {
use std::ffi::OsString;
use std::os::windows::ffi::{OsStrExt, OsStringExt};
if !path.is_absolute() {
return path.to_path_buf();
}
let path = path.as_os_str().encode_wide().collect::<Vec<_>>();
const BACKSLASH: u16 = b'\\' as u16;
const QUESTION_MARK: u16 = b'?' as u16;
if path.starts_with(&[BACKSLASH, BACKSLASH, QUESTION_MARK, BACKSLASH]) {
return PathBuf::from(OsString::from_wide(&path));
}
let mut extended = "\\\\?\\".encode_utf16().collect::<Vec<_>>();
if path.starts_with(&[BACKSLASH, BACKSLASH]) {
extended.extend("UNC\\".encode_utf16());
extended.extend_from_slice(&path[2..]);
} else {
extended.extend_from_slice(&path);
}
PathBuf::from(OsString::from_wide(&extended))
}
#[cfg(not(windows))]
fn filesystem_path(path: &Path) -> PathBuf {
path.to_path_buf()
}
fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
let mut path = root.to_path_buf();
for part in relative.split('/') {
path.push(part);
match std::fs::symlink_metadata(&path) {
match std::fs::symlink_metadata(filesystem_path(&path)) {
Ok(meta) => {
#[cfg(windows)]
{
@ -250,21 +283,32 @@ fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
Err(e) => {
return Err(
crate::util::io::IOError::with_path(e, &path).into()
);
}
}
}
Ok(path)
// Windows' legacy Win32 path parser reports ERROR_PATH_NOT_FOUND once a
// perfectly valid pack path exceeds MAX_PATH. Extended-length paths keep
// deep KubeJS/data-pack trees addressable without changing their layout.
Ok(filesystem_path(&path))
}
async fn hash(path: &Path) -> crate::Result<Option<String>> {
let mut input = match tokio::fs::File::open(path).await {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
Err(e) => {
return Err(crate::util::io::IOError::with_path(e, path).into());
}
};
let mut digest = Sha256::new();
let mut buffer = vec![0; 64 * 1024];
loop {
let n = input.read(&mut buffer).await?;
let n = input.read(&mut buffer).await.map_err(|error| {
crate::util::io::IOError::with_path(error, path)
})?;
if n == 0 {
break;
}
@ -276,10 +320,11 @@ async fn read_json<T: serde::de::DeserializeOwned>(
root: &Path,
name: &str,
) -> crate::Result<Option<T>> {
match tokio::fs::read(target(root, name)?).await {
let path = target(root, name)?;
match tokio::fs::read(&path).await {
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
Err(e) => Err(crate::util::io::IOError::with_path(e, &path).into()),
}
}
fn write_json<T: Serialize>(
@ -289,10 +334,31 @@ fn write_json<T: Serialize>(
) -> crate::Result<()> {
use std::io::Write;
let dest = target(root, name)?;
let mut file = tempfile::NamedTempFile::new_in(root)?;
file.write_all(&serde_json::to_vec(value)?)?;
file.as_file().sync_all()?;
file.persist(dest).map_err(|e| e.error)?;
let mut file = tempfile::NamedTempFile::new_in(root).map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(error, root))
.with_context(format!("创建 {name} 的临时文件失败"))
})?;
let temporary = file.path().to_path_buf();
file.write_all(&serde_json::to_vec(value)?)
.map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error, &temporary,
))
.with_context(format!("写入 {name} 的临时文件失败"))
})?;
file.as_file().sync_all().map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error, &temporary,
))
.with_context(format!("同步 {name} 的临时文件失败"))
})?;
file.persist(&dest).map_err(|error| {
crate::Error::from(error.error).with_context(format!(
"提交 {name} 失败;临时文件:{};目标文件:{}",
temporary.display(),
dest.display()
))
})?;
Ok(())
}
async fn authorization() -> crate::Result<String> {
@ -330,18 +396,22 @@ async fn request_authorized<T: serde::de::DeserializeOwned>(
auth: &str,
) -> crate::Result<T> {
ensure_session(auth).await?;
let response = configured_client()
.await?
.get(format!("{API}{suffix}"))
.header(reqwest::header::AUTHORIZATION, auth)
.header(reqwest::header::CACHE_CONTROL, "no-cache")
.timeout(std::time::Duration::from_secs(30))
.send()
.await?;
if matches!(response.status().as_u16(), 401 | 403) {
return Err(invalid(
"StarLight 登录凭据已失效或无权限,请重新登录后重试",
));
let client = configured_client().await?;
let response =
transport::metadata_request(&client, &format!("{API}{suffix}"), auth)
.await?;
if !response.status().is_success() {
let message = transport::response_failure(
&client,
response,
"https://skin.starlight.cool/starlight/user",
suffix,
auth,
)
.await;
ensure_session(auth).await?;
tracing::warn!("{message}");
return Err(invalid(message));
}
let data: Response<T> = response.error_for_status()?.json().await?;
ensure_session(auth).await?;
@ -356,10 +426,33 @@ pub async fn default_publication() -> crate::Result<Publication> {
})
}
pub async fn create() -> crate::Result<String> {
pub async fn create(
game_dir_root: Option<String>,
) -> crate::Result<String> {
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
// launcher expect a Minecraft version JSON beside the pack.
let game_dir_override = match game_dir_root
.as_deref()
.map(str::trim)
.filter(|root| !root.is_empty())
{
Some(root) => {
// The pack's game files live in their own folder under the chosen
// root, e.g. `<root>/<pack name>`. If that folder already exists
// (a previous install of the same pack, or a name clash), pick a
// suffixed sibling instead of sharing the folder with another
// instance.
let base = Path::new(root).join(&publication.manifest.name);
let resolved = unique_game_dir(&base);
Some(resolved.to_string_lossy().into_owned())
}
None => None,
};
let instance = crate::state::create_instance(
crate::state::CreateInstance {
name: publication.manifest.name.clone(),
@ -370,7 +463,7 @@ pub async fn create() -> crate::Result<String> {
icon_path: None,
link: crate::state::InstanceLink::Unmanaged,
symlink_target: None,
game_dir_override: None,
game_dir_override,
},
&state,
)
@ -394,6 +487,29 @@ pub async fn create() -> crate::Result<String> {
Ok(instance.id)
}
/// Returns `base` when its directory does not exist yet; otherwise returns the
/// first `base (n)` (n = 1, 2, …) whose directory is still free. Mirrors the
/// instance-folder de-duplication in `create_instance::resolve_instance_path`,
/// so re-installing the same hosted pack no longer makes two instances share a
/// single game folder.
fn unique_game_dir(base: &Path) -> PathBuf {
if !base.exists() {
return base.to_path_buf();
}
let parent = base.parent().unwrap_or_else(|| Path::new(""));
let name = base
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "instance".to_string());
let mut which = 1u32;
loop {
let candidate = parent.join(format!("{name} ({which})"));
if !candidate.exists() {
return candidate;
}
which += 1;
}
}
pub async fn binding(instance_id: &str) -> crate::Result<Option<Binding>> {
read_json(&crate::instance::get_full_path(instance_id).await?, BINDING)
.await
@ -433,7 +549,13 @@ async fn apply_files(
) -> crate::Result<()> {
for action in actions {
let live = target(root, &action.path)?;
if hash(&live).await? != action.old_hash {
if hash(&live).await.map_err(|error| {
error.with_context(format!(
"检查整合包目标文件失败:{}",
live.display()
))
})? != action.old_hash
{
return Err(invalid(format!(
"File changed during sync: {}",
action.path
@ -442,16 +564,82 @@ async fn apply_files(
if action.old_hash.is_some() {
let backup =
target(root, &format!("{}/{}", backup_dir, action.path))?;
tokio::fs::create_dir_all(backup.parent().unwrap()).await?;
tokio::fs::rename(&live, backup).await?;
let backup_parent = backup.parent().unwrap();
tokio::fs::create_dir_all(backup_parent).await.map_err(
|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
backup_parent,
))
.with_context(format!(
"创建整合包备份目录失败:{}",
backup_parent.display()
))
},
)?;
tokio::fs::rename(&live, &backup).await.map_err(|error| {
crate::Error::from(error).with_context(format!(
"备份整合包文件失败;源文件:{};目标文件:{}",
live.display(),
backup.display()
))
})?;
}
if let Some(next) = &action.next_hash {
tokio::fs::create_dir_all(live.parent().unwrap()).await?;
let staged =
tempfile::NamedTempFile::new_in(live.parent().unwrap())?;
tokio::fs::copy(target(cache, next)?, staged.path()).await?;
staged.as_file().sync_all()?;
staged.persist(&live).map_err(|e| e.error)?;
let live_parent = live.parent().unwrap();
tokio::fs::create_dir_all(live_parent)
.await
.map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
live_parent,
))
.with_context(format!(
"创建整合包目标目录失败:{}",
live_parent.display()
))
})?;
let staged = tempfile::NamedTempFile::new_in(live_parent).map_err(
|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
live_parent,
))
.with_context(format!(
"创建整合包临时文件失败:{}",
live.display()
))
},
)?;
let staged_path = staged.path().to_path_buf();
let cache_object = target(cache, next)?;
tokio::fs::copy(&cache_object, &staged_path)
.await
.map_err(|error| {
crate::Error::from(error).with_context(format!(
"复制整合包缓存文件失败;缓存文件:{};临时文件:{};目标文件:{}",
cache_object.display(),
staged_path.display(),
live.display()
))
})?;
staged.as_file().sync_all().map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
&staged_path,
))
.with_context(format!(
"同步整合包临时文件失败:{}",
staged_path.display()
))
})?;
staged.persist(&live).map_err(|error| {
crate::Error::from(error.error).with_context(format!(
"提交整合包文件失败;临时文件:{};目标文件:{}",
staged_path.display(),
live.display()
))
})?;
}
}
Ok(())
@ -975,7 +1163,7 @@ async fn synchronize_with_progress(
let concurrency = crate::api::settings::get()
.await?
.effective_max_concurrent_downloads()
.clamp(1, 16);
.clamp(1, MAX_HOSTED_CONCURRENT_FILES);
let current = std::sync::Mutex::new(BTreeMap::<String, u64>::new());
let completed = AtomicUsize::new(0);
progress.download(
@ -1038,6 +1226,10 @@ async fn synchronize_with_progress(
};
let mut request =
DownloadRequest::new(&url, ResourceClass::Modpack)
// The batch already downloads several files in
// parallel. Do not let an HTTP/1 fallback multiply
// every large file into another four connections.
.with_http1_segmented_download(false)
.with_integrity(Integrity {
size: Some(file_size),
sha256: Some(file.sha256),
@ -1047,14 +1239,34 @@ async fn synchronize_with_progress(
request = request
.with_header("Authorization", auth.clone());
}
download_to_path(
request,
let first_result = download_to_path(
request.clone(),
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
.await;
if let Err(error) = first_result {
tracing::warn!(
path = %file_path,
%error,
"Parallel StarLight file download failed; retrying after the batch load has eased"
);
ensure_session(auth).await?;
tokio::time::sleep(std::time::Duration::from_millis(
750,
))
.await;
download_to_path(
request,
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
}
let completed_files =
completed.fetch_add(1, Ordering::Relaxed) + 1;
let completed_bytes = {
@ -1158,12 +1370,33 @@ async fn synchronize_with_progress(
}
ensure_session(&auth).await?;
progress.update(0, 0, "正在应用更新", true);
apply_files(&root, &cache, &journal.backup, &journal.actions).await?;
apply_files(&root, &cache, &journal.backup, &journal.actions)
.await
.map_err(|error| error.with_context(format!(
"应用 StarLight 整合包文件失败;实例目录:{}",
root.display()
)))?;
progress.update(0, 0, "正在完成安装", true);
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })?;
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })
.map_err(|error| error.with_context(format!(
"写入 StarLight 整合包绑定信息失败;实例目录:{}",
root.display()
)))?;
crate::instance::edit(instance_id, EditInstance { install_stage: Some(InstanceInstallStage::Installed), ..Default::default() }).await?;
crate::instance::sync_content_files(instance_id).await?;
tokio::fs::remove_file(target(&root, JOURNAL)?).await?;
crate::instance::sync_content_files(instance_id).await.map_err(|error| {
error.with_context(format!(
"扫描 StarLight 整合包内容失败;实例目录:{}",
root.display()
))
})?;
let journal_path = target(&root, JOURNAL)?;
tokio::fs::remove_file(&journal_path).await.map_err(|error| {
crate::Error::from(crate::util::io::IOError::with_path(
error,
&journal_path,
))
.with_context("清理 StarLight 整合包安装日志失败")
})?;
Ok::<_, crate::Error>(())
}.await;
if let Err(error) = result {

View File

@ -184,6 +184,41 @@ mod tests {
assert!(root.path().join("mods/personal.jar").is_file());
}
#[tokio::test]
async fn identical_tagged_mod_keeps_the_modpack_file() {
let root = tempfile::tempdir().unwrap();
let original = PackFile {
mod_ids: vec![],
external: None,
path: "mods/example-from-pack.jar".into(),
sha256: "a".repeat(64),
size: 10,
force: true,
preserve: false,
};
let mut files = vec![original.clone()];
let mut sources = BTreeMap::from([(
original.path.clone(),
"https://example.invalid/pack-file".into(),
)]);
let mut tagged = manifest();
tagged.replaces.push(original.path.clone());
let actions = merge(
root.path(),
&root.path().join("cache"),
&mut files,
&mut sources,
&tagged,
)
.await
.unwrap();
assert!(actions.is_empty());
assert_eq!(files, vec![original]);
assert!(!sources.contains_key("mods/example.starlight.jar"));
}
#[test]
fn multi_mod_jar_cannot_be_partially_replaced() {
assert!(is_managed_file(&manifest().files[0].pack_file()));
@ -266,10 +301,33 @@ pub(super) async fn merge(
if ids.is_empty() {
return Ok(Vec::new());
}
let mut replaced: HashSet<String> =
manifest.replaces.iter().cloned().collect();
// The modpack is the installation baseline. A tagged snapshot with the
// same content already passes verification, even when the skin site uses
// a canonical filename. Keep the pack's file and only replace it when the
// tagged JAR is actually different.
let satisfied_pack_paths: HashSet<String> = manifest
.files
.iter()
.filter_map(|tagged| {
files
.iter()
.find(|file| {
is_mod_path(&file.path) && file.sha256 == tagged.sha256
})
.map(|file| file.path.clone())
})
.collect();
let mut replaced: HashSet<String> = manifest
.replaces
.iter()
.filter(|path| !satisfied_pack_paths.contains(*path))
.cloned()
.collect();
for file in files.iter().filter(|file| is_mod_path(&file.path)) {
if replaced.contains(&file.path) {
if satisfied_pack_paths.contains(&file.path)
|| replaced.contains(&file.path)
{
continue;
}
let object = target(cache, &file.sha256)?;
@ -284,6 +342,11 @@ pub(super) async fn merge(
}
files.retain(|file| !replaced.contains(&file.path));
for file in &manifest.files {
if files.iter().any(|existing| {
is_mod_path(&existing.path) && existing.sha256 == file.sha256
}) {
continue;
}
if files
.iter()
.any(|existing| existing.path.eq_ignore_ascii_case(&file.path))
@ -299,6 +362,11 @@ pub(super) async fn merge(
);
files.push(file.pack_file());
}
let desired_paths: HashSet<_> = files
.iter()
.filter(|file| is_mod_path(&file.path))
.map(|file| file.path.to_ascii_lowercase())
.collect();
let mut duplicate_actions = Vec::new();
let mods_dir = target(root, "mods")?;
if mods_dir.is_dir() {
@ -309,7 +377,9 @@ pub(super) async fn merge(
continue;
};
let path = format!("mods/{name}");
if !is_mod_path(&path) || manifest.contains(&path) {
if !is_mod_path(&path)
|| desired_paths.contains(&path.to_ascii_lowercase())
{
continue;
}
let local = target(root, &path)?;

View File

@ -0,0 +1,228 @@
use reqwest::{Client, Response, header};
pub(super) async fn metadata_request(
client: &Client,
url: &str,
auth: &str,
) -> Result<Response, reqwest::Error> {
client
.get(url)
.query(&[("starlight_request", uuid::Uuid::new_v4().to_string())])
.header(header::AUTHORIZATION, auth)
.header(header::CACHE_CONTROL, "no-cache, no-store")
.header(header::PRAGMA, "no-cache")
.timeout(std::time::Duration::from_secs(30))
.send()
.await
}
async fn bounded_json(mut response: Response) -> Option<serde_json::Value> {
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.ok()? {
if bytes.len() + chunk.len() > 16_384 {
return None;
}
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes).ok()
}
pub(super) async fn response_failure(
client: &Client,
response: Response,
user_url: &str,
suffix: &str,
auth: &str,
) -> String {
let status = response.status().as_u16();
let trace = response
.headers()
.get("eo-log-uuid")
.and_then(|value| value.to_str().ok())
.filter(|value| {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-')
})
.map(|value| format!("CDN 请求编号 {value}"))
.unwrap_or_default();
let context = format!("(整合包 HTTP {status};接口 {suffix}{trace}");
if let Some(body) = bounded_json(response).await {
if let Some(message) = body["errorMessage"]
.as_str()
.filter(|s| !s.trim().is_empty())
{
let token = auth.strip_prefix("Bearer ").unwrap_or(auth);
let message = if token.is_empty() {
message.to_owned()
} else {
message.replace(token, "[凭据已隐藏]")
};
let message: String = message
.chars()
.filter(|c| !c.is_control())
.take(500)
.collect();
return format!("StarLight 服务端返回:{message}{context}");
}
}
if !matches!(status, 401 | 403) {
return format!("StarLight 服务端请求失败{context}");
}
let detail = match metadata_request(client, user_url, auth).await {
Ok(user_response) => {
let user_status = user_response.status().as_u16();
if user_status == 200 {
let valid_user =
bounded_json(user_response).await.is_some_and(|body| {
body["payload"]["uuid"]
.as_str()
.is_some_and(|id| !id.is_empty())
&& body["payload"]["username"].is_string()
});
if valid_user {
"皮肤站登录有效,但整合包接口返回错误,且没有提供具体原因。请用请求编号查询服务端或 CDN 日志".to_owned()
} else {
"整合包接口拒绝访问,用户接口返回内容异常,无法确认登录状态"
.to_owned()
}
} else if matches!(user_status, 401 | 403) {
format!(
"整合包和用户接口均拒绝登录凭据(用户接口 HTTP {user_status})。请重新登录;若仍失败,请检查服务端鉴权及 CDN 配置"
)
} else {
format!(
"整合包接口拒绝访问,暂时无法复核登录状态(用户接口 HTTP {user_status}"
)
}
}
Err(_) => "整合包接口拒绝访问,用户接口连接失败,暂时无法复核登录状态"
.to_owned(),
};
format!("{detail}{context}")
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn hosted_auth_failure_verifies_same_credential_without_exposing_it()
{
for (pack_status, user_status, body, expected) in [
(
401,
200,
r#"{"payload":{"uuid":"owner","username":"Player"}}"#,
"皮肤站登录有效",
),
(403, 401, "{}", "均拒绝登录凭据"),
(401, 200, "<html>Proxy error</html>", "用户接口返回内容异常"),
] {
let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let mut requests = Vec::new();
for (status, body) in [(pack_status, ""), (user_status, body)] {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
request.push(socket.read_u8().await.unwrap());
}
requests.push(String::from_utf8(request).unwrap());
let response = format!(
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nEO-LOG-UUID: trace-123\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
}
requests
});
let client = Client::builder().no_proxy().build().unwrap();
let auth = "Bearer test-secret-never-display";
let response =
metadata_request(&client, &format!("{base}/default"), auth)
.await
.unwrap();
let message = response_failure(
&client,
response,
&format!("{base}/user"),
"/default",
auth,
)
.await;
assert!(message.contains(expected), "{message}");
assert!(message.contains(&format!("整合包 HTTP {pack_status}")));
assert!(message.contains("trace-123"));
assert!(!message.contains("test-secret"));
let requests = server.await.unwrap();
for (request, path) in requests.iter().zip(["/default", "/user"]) {
assert!(
request
.starts_with(&format!("GET {path}?starlight_request="))
);
let headers = request.to_ascii_lowercase();
assert!(headers.contains(
"authorization: bearer test-secret-never-display\r\n"
));
assert!(
headers.contains("cache-control: no-cache, no-store\r\n")
);
}
}
}
#[tokio::test]
async fn hosted_business_errors_preserve_server_reason_without_login_probe()
{
for status in [400, 403, 409, 500] {
let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
request.push(socket.read_u8().await.unwrap());
}
let body = r#"{"error":"IllegalState","errorMessage":"整合包 Mod 标签已删除,请管理员重新设置 test-secret-never-display"}"#;
let response = format!(
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let client = Client::builder().no_proxy().build().unwrap();
let auth = "Bearer test-secret-never-display";
let response = metadata_request(
&client,
&format!("{base}/tagged-mods/2"),
auth,
)
.await
.unwrap();
let message = response_failure(
&client,
response,
&format!("{base}/user"),
"/tagged-mods/2",
auth,
)
.await;
assert!(
message.contains("整合包 Mod 标签已删除,请管理员重新设置"),
"{message}"
);
assert!(message.contains("/tagged-mods/2"));
assert!(message.contains(&format!("HTTP {status}")));
assert!(!message.contains("test-secret"));
assert!(!message.contains("请重新登录"));
server.await.unwrap();
}
}
}

View File

@ -266,7 +266,9 @@ async fn import_atlauncher_unmanaged(
let state = State::get().await?;
finish_import(
instance_id,
minecraft_folder,
Some(minecraft_folder),
None,
false,
&state.io_semaphore,
reporter,
details,

View File

@ -142,6 +142,7 @@ pub(crate) async fn import_axolotl(
loader_version: Some(config.content_set.loader_version.clone()),
}),
launch_overrides: Some(InstanceLaunchOverridesPatch {
player: None,
instance_mode: Some(crate::state::InstanceMode::Local),
java_path: Some(config.launch_overrides.java_path.clone()),
extra_launch_args: Some(
@ -169,7 +170,9 @@ pub(crate) async fn import_axolotl(
finish_import(
instance_id,
source_path,
Some(source_path),
None,
false,
&state.io_semaphore,
reporter,
details,

View File

@ -221,7 +221,9 @@ pub async fn import_curseforge(
let state = State::get().await?;
finish_import(
instance_id,
curseforge_instance_folder,
Some(curseforge_instance_folder),
None,
false,
&state.io_semaphore,
reporter,
details,

View File

@ -118,7 +118,9 @@ pub async fn import_gdlauncher(
let state = State::get().await?;
finish_import(
instance_id,
gdlauncher_instance_folder,
Some(gdlauncher_instance_folder),
None,
false,
&state.io_semaphore,
reporter,
details,

View File

@ -3,7 +3,7 @@ use std::{
path::{Path, PathBuf},
};
use super::{ImportOverrides, instance_json};
use super::{ImportOverrides, instance_json, resolve_import_game_root};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
@ -29,29 +29,129 @@ pub async fn import_generic(
overrides: &ImportOverrides,
instance_path: Option<PathBuf>, // For compatible mode: path to versions/<version>/
) -> crate::Result<()> {
let (name, dotminecraft, json_path) = if let Some(ref inst_path) =
instance_path
{
let name = inst_path
// Resolve the source layout. Three inputs describe the same import from
// different angles and must be reconciled consistently:
//
// - `instance_folder`: the game root chosen by the caller (normally the
// `.minecraft` root for a PCL/HMCL install, or the folder itself).
// - `instance_path`: when present, the specific `versions/<name>` folder
// the user selected. A `.minecraft` root can hold many versions; only
// this one belongs to the instance being imported.
// - `overrides.game_dir_override`: the user's explicit version-isolation
// choice.
//
// The old behaviour copied/symlinked the whole `.minecraft` root and let
// the version folder dangle, which produced vanilla-only copies (mods
// stayed in versions/<name>) and cloned every sibling version too.
let layout = resolve_import_layout(
&instance_folder,
instance_path.as_deref(),
overrides.game_dir_override.as_deref(),
);
let info = detect_instance_info(&layout.json_source, overrides).await?;
register_instance(instance_id, &layout.name, &info).await?;
copy_instance_files(instance_id, &layout, reporter, details, symlink)
.await
}
/// The resolved source layout for a generic import.
///
/// `content_source` holds the shared game content (mods/saves/config) that
/// belongs to the instance; `version_dir` is the selected `versions/<name>`
/// folder. Both are merged into the instance directory by the copy/symlink
/// stage, so a version-isolated import keeps the root-level mods it used to
/// leave behind.
struct ImportLayout {
/// Display name for the instance (the version folder name when isolated,
/// otherwise the game root folder name).
name: String,
/// Directory the version JSON is detected from.
json_source: PathBuf,
/// Directory whose game content (mods/saves/config) belongs to the
/// instance. For a shared root this is the root itself; for the "move the
/// root content into versions/<name>" isolation strategy this is still the
/// root, but its content is copied *into* the instance (which then becomes
/// the game dir).
content_source: Option<PathBuf>,
/// Selected `versions/<name>` folder, when the source is a shared root.
version_dir: Option<PathBuf>,
/// Whether the instance uses version isolation.
isolated: bool,
}
/// Reconciles the three import inputs into one layout.
///
/// Rules:
/// - When `instance_path` is given it is the authoritative version folder; the
/// instance is version-isolated unless the user explicitly asked to share.
/// - When the user asked to share, the `.minecraft` root is the game dir.
/// - Without a selected version folder, fall back to the old auto-detection so
/// direct folder imports keep working.
fn resolve_import_layout(
instance_folder: &Path,
selected_version: Option<&Path>,
game_dir_override: Option<&str>,
) -> ImportLayout {
let root_name = instance_folder
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "imported".to_string());
// Explicit "version shared" choice: copy the whole `.minecraft` root.
let shared_forced = game_dir_override
.map(|dir| {
let normalized = dir.trim_end_matches(['/', '\\']);
normalized.eq_ignore_ascii_case(
instance_folder
.to_string_lossy()
.trim_end_matches(['/', '\\']),
)
})
.unwrap_or(false);
if let Some(version_dir) = selected_version {
let version_name = version_dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "imported".to_string());
tracing::debug!(
"import_generic: compatible mode - dotminecraft={}, json_path={}",
instance_folder.display(),
inst_path.display()
);
(name, instance_folder.to_path_buf(), inst_path.to_path_buf())
} else {
let (name, dotminecraft) = resolve_dotminecraft(&instance_folder);
let json_path = dotminecraft.clone(); // JSON detection will scan dotminecraft
(name, dotminecraft, json_path)
};
.unwrap_or_else(|| root_name.clone());
let info = detect_instance_info(&json_path, overrides).await?;
register_instance(instance_id, &name, &info).await?;
copy_instance_files(instance_id, &dotminecraft, reporter, details, symlink)
.await
if shared_forced {
// User explicitly chose to share the `.minecraft` root even though
// a version folder was selected.
return ImportLayout {
name: root_name,
json_source: version_dir.to_path_buf(),
content_source: Some(instance_folder.to_path_buf()),
version_dir: Some(version_dir.to_path_buf()),
isolated: false,
};
}
// Version-isolated strategy (甲): the instance becomes the game dir.
// The version files (`versions/<name>`) and the shared root content
// (mods/saves/config) are both merged into the instance, so mods that
// live at the `.minecraft` root survive the import instead of being
// left behind.
return ImportLayout {
name: version_name,
json_source: version_dir.to_path_buf(),
content_source: Some(instance_folder.to_path_buf()),
version_dir: Some(version_dir.to_path_buf()),
isolated: true,
};
}
// No explicit version folder: fall back to auto-detection.
let (name, dotminecraft) = resolve_dotminecraft(instance_folder);
let game_root = resolve_import_game_root(&dotminecraft);
ImportLayout {
name,
json_source: dotminecraft.clone(),
content_source: Some(game_root.clone()),
version_dir: None,
isolated: game_root != dotminecraft,
}
}
/// Stage 1 — resolve the name and the `.minecraft` directory of an imported
@ -325,21 +425,30 @@ async fn resolve_loader_version(
}
/// Stage 4 — copy (or symlink) the source files into the instance profile.
///
/// Uses the reconciled [`ImportLayout`]: the shared content root (mods/saves/
/// config) and the selected `versions/<name>` folder are both merged into the
/// instance directory, so a version-isolated import keeps root-level content.
async fn copy_instance_files(
instance_id: &str,
dotminecraft: &Path,
layout: &ImportLayout,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let state = State::get().await?;
tracing::debug!(
"import_generic: finishing import for instance_id={}",
instance_id
"import_generic: finishing import for instance_id={} content_source={:?} version_dir={:?} isolated={}",
instance_id,
layout.content_source,
layout.version_dir,
layout.isolated
);
finish_import(
instance_id,
dotminecraft.to_path_buf(),
layout.content_source.clone(),
layout.version_dir.clone(),
layout.isolated,
&state.io_semaphore,
reporter,
details,
@ -399,6 +508,7 @@ mod tests {
game_version: Some("1.20.1".to_string()),
loader: Some(ModLoader::Fabric),
loader_version: Some("0.15.11".to_string()),
..Default::default()
};
let info = detect_instance_info(directory.path(), &overrides)
@ -418,6 +528,7 @@ mod tests {
game_version: Some("1.20.1".to_string()),
loader: Some(ModLoader::Fabric),
loader_version: Some(loader_version.to_string()),
..Default::default()
};
let info = detect_instance_info(directory.path(), &overrides)

View File

@ -196,7 +196,8 @@ pub(crate) fn normalize_imported_loader_version(
game_version: &str,
detected_version: &str,
) -> String {
let detected_version = detected_version.trim();
let detected_version = sanitize_loader_version(detected_version);
let detected_version = detected_version.as_str();
let without_family = match loader {
"fabric" | "legacy_fabric" => detected_version
.strip_prefix("fabric-loader-")
@ -212,7 +213,7 @@ pub(crate) fn normalize_imported_loader_version(
}
.unwrap_or(detected_version);
match loader {
let normalized = match loader {
"fabric" | "legacy_fabric" | "quilt" => without_family
.strip_suffix(&format!("-{game_version}"))
.unwrap_or(without_family)
@ -232,7 +233,8 @@ pub(crate) fn normalize_imported_loader_version(
.to_string()
}
_ => without_family.to_string(),
}
};
sanitize_loader_version(&normalized)
}
fn extract_version(
@ -575,6 +577,11 @@ fn detect_adjuncts(
/// Extracts the loader version string from JSON content by finding a needle
/// and reading until a terminator character.
///
/// The terminator set includes `:` `]` `[` and whitespace because non-standard
/// launcher JSONs (notably PCL) may embed the loader coordinate in a composite
/// string such as `net.neoforged:neoforge:21.1.250:client]`, where the real
/// version ends at the first extra `:` rather than at the closing quote.
fn try_extract_version_from_needle(
content: &str,
needle: &str,
@ -582,17 +589,32 @@ fn try_extract_version_from_needle(
) -> Option<String> {
let pos = content.find(needle)?;
let after = &content[pos + needle.len()..];
let end = after.find(&['"', ',', '\n', '}'] as &[char])?;
let end = after
.find(&['"', ',', '\n', '}', ']', '[', ':', ' '] as &[char])?;
let ver = &after[..end];
if let Some(ch) = split_at
&& let Some(pos) = ver.rfind(ch)
{
Some(ver[pos + 1..].to_string())
Some(sanitize_loader_version(&ver[pos + 1..]))
} else {
Some(ver.to_string())
Some(sanitize_loader_version(ver))
}
}
/// Trims junk that non-standard launcher JSONs append to a loader coordinate
/// (e.g. `21.1.250:client]`, `44.0.3 ` or `0.15.11\n`). Keeps only the leading
/// version token so the metadata resolver receives a clean id.
fn sanitize_loader_version(raw: &str) -> String {
let trimmed = raw.trim();
// Cut at the first character that cannot appear in a loader version id.
let end = trimmed
.find(|ch: char| {
!(ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' || ch == '+')
})
.unwrap_or(trimmed.len());
trimmed[..end].trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@ -837,4 +859,35 @@ mod tests {
assert_eq!(info.loader.as_deref(), Some("fabric"));
assert_eq!(info.loader_version.as_deref(), Some("0.15.11"));
}
#[test]
fn sanitizes_loader_version_with_launcher_suffix() {
// PCL and other launchers may embed composite coordinates such as
// `net.neoforged:neoforge:21.1.250:client]`; the extracted version must
// stop at the first extra `:` instead of swallowing `:client]`.
assert_eq!(sanitize_loader_version("21.1.250:client]"), "21.1.250");
assert_eq!(sanitize_loader_version("44.0.3 "), "44.0.3");
assert_eq!(sanitize_loader_version("0.15.11\n"), "0.15.11");
assert_eq!(sanitize_loader_version("1.21.1-52.0.0"), "1.21.1-52.0.0");
assert_eq!(
sanitize_loader_version("1.7.10-10.13.4.1614-1.7.10"),
"1.7.10-10.13.4.1614-1.7.10"
);
}
#[test]
fn detect_loader_version_stops_at_extra_colon() {
assert_loader(
r#"{
"id": "1.21.1-neoforge-21.1.250",
"libraries": [
{
"name": "net.neoforged:neoforge:21.1.250:client]"
}
]
}"#,
"neoforge",
Some("21.1.250"),
);
}
}

View File

@ -303,7 +303,9 @@ async fn import_mmc_unmanaged(
let state = State::get().await?;
finish_import(
instance_id,
minecraft_folder,
Some(minecraft_folder),
None,
false,
&state.io_semaphore,
reporter,
details,

View File

@ -661,6 +661,10 @@ pub(crate) struct ImportOverrides {
pub game_version: Option<String>,
pub loader: Option<ModLoader>,
pub loader_version: Option<String>,
/// The user's explicit game directory (version isolation choice). When set,
/// it is the absolute path the instance should use as its working
/// directory; when `None`, the layout is auto-detected.
pub game_dir_override: Option<String>,
}
pub(crate) async fn import_instance_with_reporter(
@ -1045,7 +1049,9 @@ pub async fn recache_icon(
pub(crate) async fn copy_dotminecraft_with_reporter(
instance_id: &str,
dotminecraft: PathBuf,
content_source: Option<PathBuf>,
version_dir: Option<PathBuf>,
isolated: bool,
io_semaphore: &IoSemaphore,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
@ -1053,7 +1059,36 @@ pub(crate) async fn copy_dotminecraft_with_reporter(
let instance_path =
crate::api::instance::get_full_path(instance_id).await?;
let files = collect_dotminecraft_files(&dotminecraft).await?;
let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
if let Some(content_root) = &content_source {
// Copy the shared content (mods/saves/config/…). When a specific
// version folder is in play, every sibling under `versions/` belongs to
// a different instance and must not be cloned here.
//
// - shared import: keep the selected version, drop the rest;
// - isolated import (甲): drop the whole `versions/` tree here — the
// selected version is copied separately below and merged into the
// instance root.
let keep_version = if isolated {
None
} else {
version_dir.as_deref()
};
let mut content_files =
collect_dotminecraft_files(content_root, keep_version, isolated)
.await?;
files.append(&mut content_files);
}
if isolated && let Some(version) = &version_dir {
// Merge the selected version files (`<name>.json`, `<name>.jar`, and any
// nested `mods/`, `config/`, … that live inside the version folder)
// directly into the instance root so the instance directory becomes a
// self-contained game dir.
let mut version_files = collect_version_files(version).await?;
files.append(&mut version_files);
}
let total = files.len() as u64;
if total == 0 {
@ -1085,6 +1120,8 @@ pub(crate) async fn copy_dotminecraft_with_reporter(
/// at the source root (`<dirname>.json` and `<dirname>.jar`).
async fn collect_dotminecraft_files(
dotminecraft: &Path,
keep_version: Option<&Path>,
isolated: bool,
) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
// Collect all files recursively
let files = get_all_subfiles(dotminecraft, false).await?;
@ -1098,6 +1135,14 @@ async fn collect_dotminecraft_files(
let skip_json = format!("{dirname}.json");
let skip_jar = format!("{dirname}.jar");
// When a specific version folder is requested from a shared `.minecraft`
// root, every other entry under `versions/` belongs to a different
// instance. Resolve the relative keep-path once so the loop can compare
// cheaply (e.g. `versions/1.21.1-NeoForge_21.1.250`).
let keep_relative = keep_version
.and_then(|version| version.strip_prefix(dotminecraft).ok())
.map(|rel| rel.to_path_buf());
let mut collected = Vec::new();
for abs_path in files {
let metadata = tokio::fs::symlink_metadata(&abs_path)
@ -1117,6 +1162,23 @@ async fn collect_dotminecraft_files(
else {
continue;
};
// In the isolated strategy the whole `versions/` tree is handled
// separately (only the selected version is copied, and it is merged
// into the instance root), so skip it entirely here to avoid cloning
// sibling versions.
if isolated {
if rel.components().next().map(|c| c.as_os_str())
== Some("versions".as_ref())
{
continue;
}
} else if let Some(keep) = &keep_relative
&& is_other_version_entry(&rel, keep)
{
continue;
}
if rel
.parent()
.is_some_and(|path| !path.as_os_str().is_empty())
@ -1132,6 +1194,70 @@ async fn collect_dotminecraft_files(
Ok(collected)
}
/// Collects every file inside a selected `versions/<name>` folder, mapping each
/// path relative to that folder so the contents merge directly into the
/// instance root (the instance becomes a self-contained, version-isolated game
/// dir).
async fn collect_version_files(
version_dir: &Path,
) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
let files = get_all_subfiles(version_dir, false).await?;
let mut collected = Vec::new();
for abs_path in files {
let metadata = tokio::fs::symlink_metadata(&abs_path)
.await
.map_err(|error| IOError::with_path(error, &abs_path))?;
if crate::util::io::is_symlink_or_reparse(&metadata) {
tracing::warn!(
path = %abs_path.display(),
"Skipping nested symlink or reparse point while copying a version folder"
);
continue;
}
if let Ok(rel) = abs_path.strip_prefix(version_dir) {
collected.push((abs_path, rel.to_path_buf()));
}
}
Ok(collected)
}
/// True if `rel` lives under `versions/` but does not belong to the selected
/// version folder `keep` (which is itself relative to the `.minecraft` root,
/// e.g. `versions/1.21.1-NeoForge_21.1.250`).
///
/// `rel` may be the version directory itself, a file directly inside it, or a
/// path nested deeper. Anything sharing the first two path components with
/// `keep` is kept; every other `versions/<other>` entry is excluded.
fn is_other_version_entry(rel: &Path, keep: &Path) -> bool {
let mut rel_components = rel.components();
let mut keep_components = keep.components();
// Both must start with the literal `versions` component.
if rel_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) {
return false;
}
if keep_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) {
return false;
}
let rel_version = rel_components.next().map(|c| c.as_os_str());
let keep_version = keep_components.next().map(|c| c.as_os_str());
// `rel` is always a *file* path relative to the `.minecraft` root. A file
// that sits directly under `versions/` (e.g. `versions/version_manifest.json`)
// has exactly two components and is shared metadata, not a version folder:
// leave it alone. Only when there is at least a third component
// (`versions/<name>/<file>`) can the second component be treated as a
// version directory name.
let rel_is_inside_version_dir = rel_components.next().is_some();
match (rel_version, keep_version) {
(Some(rel_v), Some(keep_v)) if rel_is_inside_version_dir => rel_v != keep_v,
// A file directly under `versions/`, shared metadata: keep it.
_ => false,
}
}
/// Copies the collected files into the instance profile concurrently, bounded
/// by the I/O semaphore, reporting progress after every completed file.
async fn copy_files_with_progress(
@ -1225,7 +1351,7 @@ async fn copy_files_with_progress(
/// back to the source folder itself: the game creates the content folders
/// there on first run, and for imports the user's explicit game-dir choice
/// (or no override, i.e. the managed symlink) decides the rest.
fn resolve_import_game_root(source: &Path) -> PathBuf {
pub(crate) fn resolve_import_game_root(source: &Path) -> PathBuf {
// The source is itself the game root: either a whole Minecraft folder that
// carries a game body, or any folder that already holds game content
// (a version-isolated `versions/<name>` with mods/saves/config inside).
@ -1300,19 +1426,27 @@ fn dir_has_game_content(root: &Path) -> bool {
pub(crate) async fn finish_import(
instance_id: &str,
dotminecraft: PathBuf,
content_source: Option<PathBuf>,
version_dir: Option<PathBuf>,
isolated: bool,
io_semaphore: &IoSemaphore,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let local_source = LocalRuntimeSource::discover(&dotminecraft);
// The directory the game body / version JSON lives in, used to discover the
// local runtime source. Prefer the selected version folder, else the
// content root.
let primary_source = version_dir
.clone()
.or_else(|| content_source.clone())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Import has no content source".to_string(),
)
})?;
let local_source = LocalRuntimeSource::discover(&primary_source);
// Respect an explicitly chosen game-dir override (the user's isolated /
// not-isolated selection, already stored on the instance row at creation).
// Only fall back to auto-detection for symlink imports that did not carry
// an explicit override, so copy imports always stay built-in (no override)
// and the frontend's choice is never clobbered.
let state = crate::state::State::get().await?;
let pool = &state.pool;
let existing_override =
@ -1324,17 +1458,15 @@ pub(crate) async fn finish_import(
.map(|(_, override_dir)| override_dir)
.unwrap_or(None);
if existing_override.is_none() && symlink {
// For a non-version-isolated import the game content (mods, saves, config)
// lives in the `.minecraft` root, not in the detected `versions/<name>`
// subfolder. Detect that and record the override so the instance uses the
// real game root directly instead of an empty version subfolder.
let game_root = resolve_import_game_root(&dotminecraft);
if game_root != dotminecraft {
// For a symlinked import the game dir is the referenced source root,
// not the empty managed instance folder. Record it so the instance
// launches from the real location.
if let Some(content_root) = &content_source {
crate::state::edit_instance(
instance_id,
crate::state::EditInstance {
game_dir_override: Some(Some(
game_root.to_string_lossy().to_string(),
content_root.to_string_lossy().to_string(),
)),
..Default::default()
},
@ -1345,6 +1477,11 @@ pub(crate) async fn finish_import(
}
if symlink {
let source_root = content_source.clone().ok_or_else(|| {
crate::ErrorKind::InputError(
"Symlink import requires a content source".to_string(),
)
})?;
let state = State::get().await?;
let relative_path =
instance_rows::get_instance_path_by_id(instance_id, &state.pool)
@ -1354,7 +1491,7 @@ pub(crate) async fn finish_import(
})?;
// The instance's managed folder lives at instances_dir/<path>. This is
// where the symlink is created; it must NOT go through the game-dir
// override (which points at the external .minecraft root).
// override (which points at the external source root).
let instance_path =
state.directories.instances_dir().join(&relative_path);
@ -1402,7 +1539,7 @@ pub(crate) async fn finish_import(
return Err(error.into());
}
if let Err(error) =
io::create_symlink(&dotminecraft, &instance_path).await
io::create_symlink(&source_root, &instance_path).await
{
let _ = io::rename_or_move(&backup_path, &instance_path).await;
watch_instance_folder(
@ -1423,14 +1560,14 @@ pub(crate) async fn finish_import(
)
.await;
} else {
io::create_symlink(&dotminecraft, &instance_path).await?;
io::create_symlink(&source_root, &instance_path).await?;
}
crate::state::edit_instance(
instance_id,
crate::state::EditInstance {
symlink_target: Some(Some(
dotminecraft.to_string_lossy().to_string(),
source_root.to_string_lossy().to_string(),
)),
..Default::default()
},
@ -1440,7 +1577,9 @@ pub(crate) async fn finish_import(
} else {
copy_dotminecraft_with_reporter(
instance_id,
dotminecraft,
content_source,
version_dir,
isolated,
io_semaphore,
reporter.clone(),
details,

View File

@ -210,7 +210,9 @@ pub async fn import_instance(
let state = State::get().await?;
finish_import(
instance_id,
source,
Some(source),
None,
false,
&state.io_semaphore,
reporter,
details,

View File

@ -1,7 +1,7 @@
pub(crate) mod archive_util;
pub mod detect;
pub mod import;
pub mod hosted;
pub mod import;
pub mod install_from;
pub(crate) mod install_hmcl;
pub(crate) mod install_mcbbs;

View File

@ -16,7 +16,10 @@ mod tests {
fn user_agent_is_unique_and_contains_no_contact_information() {
let user_agent = user_agent("1.2.3", "windows");
assert_eq!(user_agent, "garbage-human-studio/starlight/1.2.3 (windows)");
assert_eq!(
user_agent,
"garbage-human-studio/starlight/1.2.3 (windows)"
);
assert!(!user_agent.contains("ghs.red"));
assert!(!user_agent.contains("http"));
assert!(!user_agent.contains('@'));

View File

@ -249,7 +249,11 @@ impl std::fmt::Display for Error {
impl Error {
pub(crate) fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
let context = context.into();
self.context = Some(match self.context.take() {
Some(existing) => format!("{existing}\n{context}"),
None => context,
});
self
}

View File

@ -1529,10 +1529,10 @@ async fn run_request(
game_version,
loader,
loader_version,
game_dir_override: _,
game_dir_override,
} => {
tracing::debug!(
"InstallRequest::ImportInstance: launcher_type={launcher_type} base_path={} instance_folder={instance_folder} symlink={symlink}",
"InstallRequest::ImportInstance: launcher_type={launcher_type} base_path={} instance_folder={instance_folder} symlink={symlink} game_dir_override={game_dir_override:?}",
base_path.display()
);
let Some(instance_id) = current_instance_id(job_state) else {
@ -1562,6 +1562,7 @@ async fn run_request(
game_version,
loader,
loader_version,
game_dir_override,
},
// TODO(B2): apply overrides to launcher-specific importers
// (MultiMC/Prism/ATLauncher/GDLauncher/Curseforge/ModrinthApp);
@ -1590,8 +1591,12 @@ async fn run_request(
let state = State::get().await?;
crate::api::pack::import::copy_dotminecraft_with_reporter(
&instance_id,
crate::api::instance::get_full_path(&source_instance_id)
.await?,
Some(
crate::api::instance::get_full_path(&source_instance_id)
.await?,
),
None,
false,
&state.io_semaphore,
InstallProgressReporter::new(job_id, job_state.clone()),
InstallPhaseDetails::Empty,
@ -2526,7 +2531,9 @@ async fn copy_physical_instance_contents(
)?;
crate::api::pack::import::copy_dotminecraft_with_reporter(
target_instance_id,
source_path,
Some(source_path),
None,
false,
&state.io_semaphore,
InstallProgressReporter::new(job_id, job_state.clone()),
InstallPhaseDetails::Empty,

View File

@ -293,130 +293,30 @@ pub(crate) fn linked_native_plan(
}))
}
/// Persistent cache of "this file was already hash-verified" stamps, keyed by
/// absolute path and validated against the file's size and mtime. Asset
/// objects and libraries number in the thousands on modern packs; re-hashing
/// every byte on each launch is pure disk churn (and dominates cold-start on
/// mechanical drives). Content-addressed files never change in place, so a
/// matching (size, mtime) stamp lets us trust a previous verification.
///
/// The cache lives in Axolotl's own caches directory, never in the linked
/// installation. It only ever *skips* a re-hash when the stamp matches;
/// a modified or replaced file falls back to a full SHA1 check.
static VERIFY_STAMPS: std::sync::OnceLock<
std::sync::Mutex<HashMap<String, (u64, u128)>>,
> = std::sync::OnceLock::new();
static VERIFY_STAMPS_PATH: std::sync::OnceLock<PathBuf> =
std::sync::OnceLock::new();
static VERIFY_STAMPS_LOADED: std::sync::OnceLock<()> =
std::sync::OnceLock::new();
static VERIFY_STAMPS_DIRTY: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn verify_stamps() -> &'static std::sync::Mutex<HashMap<String, (u64, u128)>>
{
VERIFY_STAMPS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
/// Loads the stamp cache once per process. Safe to call from every entry
/// point; only the first call does work.
async fn load_verify_stamps(st: &State) {
if VERIFY_STAMPS_LOADED.set(()).is_err() {
return;
}
let path = st
.directories
.caches_dir()
.join("linked-verify-stamps.json");
if let Ok(bytes) = tokio::fs::read(&path).await
&& let Ok(map) =
serde_json::from_slice::<HashMap<String, (u64, u128)>>(&bytes)
{
if let Ok(mut guard) = verify_stamps().lock() {
*guard = map;
}
}
let _ = VERIFY_STAMPS_PATH.set(path);
}
/// Writes the stamp cache back if anything changed this session. Best-effort:
/// a failure only costs us the next session's re-hash.
async fn flush_verify_stamps() {
if !VERIFY_STAMPS_DIRTY.swap(false, std::sync::atomic::Ordering::SeqCst) {
return;
}
let Some(path) = VERIFY_STAMPS_PATH.get() else {
return;
};
let snapshot = match verify_stamps().lock() {
Ok(guard) => guard.clone(),
Err(_) => return,
};
let Ok(json) = serde_json::to_vec(&snapshot) else {
return;
};
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(path, json).await;
}
fn file_mtime_nanos(metadata: &std::fs::Metadata) -> Option<u128> {
metadata
.modified()
.ok()
.and_then(|time| {
time.duration_since(std::time::UNIX_EPOCH).ok()
})
.map(|duration| duration.as_nanos())
}
/// Whether the file exists and satisfies the metadata available for it.
/// SHA1 is authoritative when declared; otherwise a declared size still
/// protects against accepting a partial or truncated file. An unreadable file
/// counts as not current so it gets replaced. A previously recorded
/// (size, mtime) stamp short-circuits the SHA1 read for unchanged files.
/// counts as not current so it gets replaced.
async fn file_is_current(
path: &std::path::Path,
expected_sha1: Option<&str>,
expected_size: Option<u64>,
) -> bool {
let metadata = match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => metadata,
_ => return false,
};
if !path.is_file() {
return false;
}
if let Some(expected_size) = expected_size
&& metadata.len() != expected_size
&& std::fs::metadata(path)
.map_or(true, |metadata| metadata.len() != expected_size)
{
return false;
}
let Some(expected) = expected_sha1 else {
return true;
};
let mtime = file_mtime_nanos(&metadata);
let key = path.to_string_lossy().into_owned();
if let Some(mtime) = mtime
&& let Ok(guard) = verify_stamps().lock()
&& let Some(&(size, stamp_mtime)) = guard.get(&key)
&& size == metadata.len()
&& stamp_mtime == mtime
{
return true;
}
match fetch::sha1_file_async(path).await {
Ok((_, actual)) if actual.eq_ignore_ascii_case(expected) => {
if let Some(mtime) = mtime
&& let Ok(mut guard) = verify_stamps().lock()
{
guard.insert(key, (metadata.len(), mtime));
VERIFY_STAMPS_DIRTY
.store(true, std::sync::atomic::Ordering::SeqCst);
}
true
}
_ => false,
match expected_sha1 {
Some(expected) => match fetch::sha1_file_async(path).await {
Ok((_, actual)) => actual.eq_ignore_ascii_case(expected),
Err(_) => false,
},
None => true,
}
}
@ -575,48 +475,18 @@ pub(crate) async fn ensure_linked_assets_from(
};
let objects_dir = direct.assets_dir().join("objects");
// Assets number in the thousands on modern versions; hashing each object
// serially accounted for the bulk of launch latency. Fan the checks out
// across a bounded pool (same width the downloader uses).
let __t_asset_scan = std::time::Instant::now();
let asset_limit = download_util::task_concurrency_limit(st)
.map(|limit| limit.saturating_mul(2))
.unwrap_or(FALLBACK_CONCURRENCY);
let missing_slot = std::sync::Arc::new(std::sync::Mutex::new(
Vec::<(String, u64, PathBuf)>::new(),
));
let asset_entries: Vec<(String, u64)> = index
.objects
.values()
.filter(|asset| asset.hash.len() >= 2)
.map(|asset| (asset.hash.clone(), u64::from(asset.size)))
.collect();
stream::iter(asset_entries)
.map(Ok::<_, crate::Error>)
.try_for_each_concurrent(asset_limit, |(hash, size)| {
let missing_slot = std::sync::Arc::clone(&missing_slot);
let objects_dir = objects_dir.clone();
async move {
let destination = objects_dir.join(&hash[..2]).join(&hash);
if !file_is_current(
&destination,
Some(&hash),
Some(size),
)
.await
{
missing_slot.lock().unwrap().push((hash, size, destination));
}
Ok(())
}
})
.await?;
let missing: Vec<(String, u64, PathBuf)> = std::sync::Arc::try_unwrap(
missing_slot,
)
.map(|mutex| mutex.into_inner().unwrap())
.unwrap_or_default();
tracing::info!("[launch-timing] asset_scan: {}ms (checked {}, missing {})", __t_asset_scan.elapsed().as_millis(), index.objects.len(), missing.len());
let mut missing = Vec::new();
for asset in index.objects.values() {
let hash = &asset.hash;
if hash.len() < 2 {
continue;
}
let destination = objects_dir.join(&hash[..2]).join(hash);
let size = u64::from(asset.size);
if !file_is_current(&destination, Some(hash), Some(size)).await {
missing.push((hash.clone(), size, destination));
}
}
if !missing.is_empty() {
tracing::info!(
count = missing.len(),
@ -750,7 +620,6 @@ pub(crate) async fn ensure_direct_launch_dependencies(
java_arch: &str,
minecraft_updated: bool,
) -> crate::Result<()> {
load_verify_stamps(st).await;
let mut plans = Vec::new();
if let Some(plan) = linked_client_plan(direct, version_info) {
plans.push(plan);
@ -779,41 +648,14 @@ pub(crate) async fn ensure_direct_launch_dependencies(
// Only fetch what is actually missing so a healthy installation performs
// zero network requests.
let __t_libscan = std::time::Instant::now();
let plans_len_dbg = plans.len();
// Verify every planned file concurrently. Hashing a few hundred jars
// serially dominated cold-start time; the SHA1 reads are independent so
// they fan out across the same bounded pool the downloader uses.
let lib_limit = download_util::task_concurrency_limit(st)
.map(|limit| limit.saturating_mul(2))
.unwrap_or(FALLBACK_CONCURRENCY);
let pending_slot = std::sync::Arc::new(std::sync::Mutex::new(
Vec::<LinkedFilePlan>::new(),
));
stream::iter(plans)
.map(Ok::<_, crate::Error>)
.try_for_each_concurrent(lib_limit, |plan| {
let pending_slot = std::sync::Arc::clone(&pending_slot);
async move {
if !file_is_current(
&plan.destination,
plan.sha1.as_deref(),
plan.size,
)
.await
{
pending_slot.lock().unwrap().push(plan);
}
Ok(())
}
})
.await?;
let pending: Vec<LinkedFilePlan> = std::sync::Arc::try_unwrap(
pending_slot,
)
.map(|mutex| mutex.into_inner().unwrap())
.unwrap_or_default();
tracing::info!("[launch-timing] dep_lib_scan: {}ms (checked {}, missing {})", __t_libscan.elapsed().as_millis(), plans_len_dbg, pending.len());
let mut pending = Vec::new();
for plan in plans {
if !file_is_current(&plan.destination, plan.sha1.as_deref(), plan.size)
.await
{
pending.push(plan);
}
}
if !pending.is_empty() {
tracing::info!(
count = pending.len(),
@ -830,7 +672,6 @@ pub(crate) async fn ensure_direct_launch_dependencies(
.await?;
}
let __t_assets = std::time::Instant::now();
ensure_linked_assets(
st,
direct,
@ -838,11 +679,7 @@ pub(crate) async fn ensure_direct_launch_dependencies(
version_info.assets == "legacy",
)
.await?;
tracing::info!("[launch-timing] dep_assets: {}ms", __t_assets.elapsed().as_millis());
let __t_logcfg = std::time::Instant::now();
ensure_linked_log_config(st, direct, version_info.logging.as_ref()).await?;
tracing::info!("[launch-timing] dep_log_config: {}ms", __t_logcfg.elapsed().as_millis());
flush_verify_stamps().await;
Ok(())
}

View File

@ -24,11 +24,14 @@ enum LanguageCodeStyle {
/// directory (e.g. modpacks that ship a preconfigured `options.txt`). For
/// instances the player already uses, their in-game choice is kept and only
/// its casing is normalized for the game version to avoid resets or crashes.
/// The font preference is initialized independently of the language, only
/// when a fresh instance has no explicit font choice in its options file.
pub fn game_language_options(
launcher_locale: &str,
game_release_time: DateTime<Utc>,
options_txt: &str,
has_saves: bool,
force_unicode_font: bool,
) -> Vec<(String, String)> {
let style = match language_code_style(game_release_time) {
LanguageCodeStyle::Unsupported => return Vec::new(),
@ -47,16 +50,21 @@ pub fn game_language_options(
.as_deref()
.and_then(|code| normalize_language_code(code, legacy_region_case))
};
let Some(desired) = desired else {
return Vec::new();
};
let mut options = Vec::new();
if current.as_deref() != Some(desired.as_str()) {
if let Some(desired) = desired
&& current.as_deref() != Some(desired.as_str())
{
options.push(("lang".to_string(), desired));
}
if fresh && needs_unicode_font(launcher_locale) {
options.push(("forceUnicodeFont".to_string(), "true".to_string()));
if fresh
&& !options_txt
.lines()
.any(|line| line.starts_with("forceUnicodeFont:"))
{
options.push((
"forceUnicodeFont".to_string(),
force_unicode_font.to_string(),
));
}
options
}
@ -105,20 +113,6 @@ fn normalize_language_code(
}
}
/// CJK glyphs are not covered by the game's default bitmap font in older
/// versions, so first-time setups for these languages also force the
/// unicode font.
fn needs_unicode_font(launcher_locale: &str) -> bool {
launcher_locale
.split(['-', '_'])
.next()
.is_some_and(|language| {
language.eq_ignore_ascii_case("zh")
|| language.eq_ignore_ascii_case("ja")
|| language.eq_ignore_ascii_case("ko")
})
}
#[cfg(test)]
mod tests {
use super::*;
@ -142,10 +136,10 @@ mod tests {
#[test]
fn fresh_instance_follows_launcher_language() {
assert_eq!(
game_language_options("zh-CN", modern(), "", false),
game_language_options("zh-CN", modern(), "", false, false),
vec![
("lang".to_string(), "zh_cn".to_string()),
("forceUnicodeFont".to_string(), "true".to_string()),
("forceUnicodeFont".to_string(), "false".to_string()),
]
);
}
@ -153,26 +147,50 @@ mod tests {
#[test]
fn legacy_versions_use_uppercase_region() {
assert_eq!(
game_language_options("zh-CN", legacy(), "", false),
game_language_options("zh-CN", legacy(), "", false, false),
vec![
("lang".to_string(), "zh_CN".to_string()),
("forceUnicodeFont".to_string(), "true".to_string()),
("forceUnicodeFont".to_string(), "false".to_string()),
]
);
}
#[test]
fn non_cjk_languages_skip_unicode_font() {
fn unicode_font_can_be_enabled_for_any_language() {
for locale in ["zh-CN", "zh-TW", "ja-JP", "ko-KR", "en-US", ""] {
let options =
game_language_options(locale, modern(), "", false, true);
assert!(
options.contains(&(
"forceUnicodeFont".to_string(),
"true".to_string()
)),
"{locale}"
);
}
}
#[test]
fn non_cjk_languages_also_use_the_font_default() {
assert_eq!(
game_language_options("en-US", modern(), "", false),
vec![("lang".to_string(), "en_us".to_string())]
game_language_options("en-US", modern(), "", false, false),
vec![
("lang".to_string(), "en_us".to_string()),
("forceUnicodeFont".to_string(), "false".to_string()),
]
);
}
#[test]
fn versions_before_1_1_are_left_alone() {
assert_eq!(
game_language_options("zh-CN", release(2011, 11, 17), "", false),
game_language_options(
"zh-CN",
release(2011, 11, 17),
"",
false,
true
),
Vec::new()
);
}
@ -184,6 +202,7 @@ mod tests {
"zh-CN",
modern(),
"fullscreen:false\nlang:ja_jp\n",
true,
true
),
Vec::new()
@ -193,7 +212,13 @@ mod tests {
#[test]
fn played_instances_get_their_casing_normalized() {
assert_eq!(
game_language_options("en-US", modern(), "lang:zh_CN\n", true),
game_language_options(
"en-US",
modern(),
"lang:zh_CN\n",
true,
true
),
vec![("lang".to_string(), "zh_cn".to_string())]
);
}
@ -201,10 +226,16 @@ mod tests {
#[test]
fn preconfigured_language_without_saves_is_overridden() {
assert_eq!(
game_language_options("zh-TW", modern(), "lang:en_us\n", false),
game_language_options(
"zh-TW",
modern(),
"lang:en_us\n",
false,
false
),
vec![
("lang".to_string(), "zh_tw".to_string()),
("forceUnicodeFont".to_string(), "true".to_string()),
("forceUnicodeFont".to_string(), "false".to_string()),
]
);
}
@ -212,16 +243,25 @@ mod tests {
#[test]
fn matching_language_needs_no_update() {
assert_eq!(
game_language_options("ja-JP", modern(), "lang:ja_jp\n", true),
game_language_options(
"ja-JP",
modern(),
"lang:ja_jp\n",
true,
false
),
Vec::new()
);
}
#[test]
fn empty_locale_makes_no_changes() {
assert_eq!(game_language_options("", modern(), "", false), Vec::new());
fn empty_locale_only_initializes_the_font_default() {
assert_eq!(
game_language_options("", modern(), "lang:zh_cn\n", true),
game_language_options("", modern(), "", false, false),
vec![("forceUnicodeFont".to_string(), "false".to_string())]
);
assert_eq!(
game_language_options("", modern(), "lang:zh_cn\n", true, false),
Vec::new()
);
}
@ -229,8 +269,39 @@ mod tests {
#[test]
fn crlf_options_files_are_parsed() {
assert_eq!(
game_language_options("ko-KR", modern(), "lang:ko_kr\r\n", true),
game_language_options(
"ko-KR",
modern(),
"lang:ko_kr\r\n",
true,
false
),
Vec::new()
);
}
#[test]
fn existing_font_choices_are_preserved_even_without_saves_or_language() {
for has_saves in [false, true] {
for enabled in [false, true] {
for language in ["", "lang:zh_cn\r\n"] {
let options_txt = format!(
"{language}forceUnicodeFont:{enabled}\r\nfullscreen:false\r\n"
);
let options = game_language_options(
"zh-CN",
modern(),
&options_txt,
has_saves,
!enabled,
);
assert!(
options
.iter()
.all(|(key, _)| key != "forceUnicodeFont")
);
}
}
}
}
}

View File

@ -1525,11 +1525,8 @@ pub async fn launch_minecraft(
) -> crate::Result<ProcessMetadata> {
let instance = &context.instance;
let content_set = &context.applied_content_set;
let mut __lt = std::time::Instant::now();
let mut combined_java_args =
crate::pack::hosted::java_arguments(&instance.id).await?;
tracing::info!("[launch-timing] hosted_java_arguments: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
combined_java_args.extend_from_slice(java_args);
let java_args = combined_java_args.as_slice();
@ -1835,8 +1832,6 @@ pub async fn launch_minecraft(
.await?;
}
}
tracing::info!("[launch-timing] resolve_version_info: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
let java_version = if let Some(java) =
context.launch_overrides.java_path.as_ref()
@ -1891,8 +1886,6 @@ pub async fn launch_minecraft(
let java_version =
crate::api::jre::check_jre(java_version.path.clone().into()).await?;
validate_loader_java_version(&version_info, &java_version)?;
tracing::info!("[launch-timing] resolve_java: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
// Runtime-verify and fall back for GC arguments against the *actual* JVM
// that will run Minecraft. The frontend supplies an ordered candidate
@ -1922,8 +1915,6 @@ pub async fn launch_minecraft(
}
*gc_report = Some(report);
}
tracing::info!("[launch-timing] resolve_gc: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
// Apply the mappable linked-launcher private settings for directly
// associated instances on top of the resolved launch configuration.
@ -2029,8 +2020,6 @@ pub async fn launch_minecraft(
None => vanilla_client_path,
}
};
tracing::info!("[launch-timing] assemble_client: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
let args = version_info.arguments.clone().unwrap_or_default();
let mut command = match wrapper {
@ -2076,7 +2065,6 @@ pub async fn launch_minecraft(
&& let (Some(direct), Some(libraries)) =
(&direct_launch, linked_libraries.as_deref())
{
let __t_ensure = std::time::Instant::now();
direct_ensure::ensure_direct_launch_dependencies(
&state,
direct,
@ -2086,7 +2074,6 @@ pub async fn launch_minecraft(
minecraft_updated,
)
.await?;
tracing::info!("[launch-timing] ensure_deps(libs+assets+log): {}ms", __t_ensure.elapsed().as_millis());
}
let natives_dir = if let Some(direct) = &direct_launch {
@ -2098,11 +2085,9 @@ pub async fn launch_minecraft(
};
// Linked native archives can change outside Axolotl, so rebuild only the
// Axolotl-owned linked cache on every launch. Never mutate linked folders.
let __t_rm = std::time::Instant::now();
if direct_launch.is_some() && natives_dir.exists() {
io::remove_dir_all(&natives_dir).await?;
}
tracing::info!("[launch-timing] remove_old_natives: {}ms", __t_rm.elapsed().as_millis());
if !natives_dir.exists() {
io::create_dir_all(&natives_dir).await?;
}
@ -2113,7 +2098,6 @@ pub async fn launch_minecraft(
// launch; the managed restore path below must never touch them.
let target = natives_dir.clone();
let java_arch = java_version.architecture.clone();
let __t_extract = std::time::Instant::now();
tokio::task::spawn_blocking(move || {
extract_linked_natives(
&direct,
@ -2124,7 +2108,6 @@ pub async fn launch_minecraft(
)
})
.await??;
tracing::info!("[launch-timing] extract_linked_natives: {}ms", __t_extract.elapsed().as_millis());
} else if direct_launch.is_none() {
if offline_mode {
natives::prepare_native_libraries(
@ -2154,8 +2137,6 @@ pub async fn launch_minecraft(
}
}
tracing::info!("[launch-timing] ensure_libraries_and_natives: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
tracing::debug!(
"Found QuickPlayVersion for {}: {quick_play_version:?}",
content_set.game_version
@ -2345,6 +2326,7 @@ pub async fn launch_minecraft(
&& (!mc_set_options.is_empty()
|| offline_skin_pack.enabled_pack_id.is_some()
|| options_existed
|| settings.force_unicode_font
|| !settings.locale.is_empty())
{
let (mut options_string, input_encoding) = if options_existed {
@ -2372,6 +2354,7 @@ pub async fn launch_minecraft(
launch_release_time,
&options_string,
instance_path.join("saves").exists(),
settings.force_unicode_font,
);
if !mc_set_options.is_empty()
@ -2418,7 +2401,7 @@ pub async fn launch_minecraft(
let logs_folder = state.directories.game_logs_dir(&instance_path);
// Create Minecraft child by inserting it into the state
// This also spawns the process and prepares the subsequent processes
let __launch_process = state
state
.process_manager
.insert_new_process(
&instance.id,
@ -2462,9 +2445,7 @@ pub async fn launch_minecraft(
Ok(())
},
)
.await;
tracing::info!("[launch-timing] process_spawn: {}ms", __lt.elapsed().as_millis());
__launch_process
.await
}
#[cfg(test)]

View File

@ -22,17 +22,12 @@ const DEFAULT_CONSOLE_COLUMNS: usize = 80;
#[cfg(debug_assertions)]
const CONSOLE_TRUNCATION_MARKER: &str = "... [console output truncated]";
#[cfg(not(debug_assertions))]
const LAUNCHER_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
#[cfg(not(debug_assertions))]
const LAUNCHER_WARN_ERROR_MAX_BYTES: u64 = 30 * 1024 * 1024;
#[cfg(not(debug_assertions))]
const LAUNCHER_LOG_MAX_FILES: usize = 5;
#[cfg(not(debug_assertions))]
const LAUNCHER_LOG_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(3 * 24 * 60 * 60);
#[cfg(any(test, not(debug_assertions)))]
#[derive(Clone)]
struct RotatingLogWriter {
state: std::sync::Arc<std::sync::Mutex<RotatingLogState>>,
@ -40,7 +35,6 @@ struct RotatingLogWriter {
warn_error_max_bytes: u64,
}
#[cfg(any(test, not(debug_assertions)))]
struct RotatingLogState {
logs_dir: std::path::PathBuf,
session_name: String,
@ -51,7 +45,6 @@ struct RotatingLogState {
max_age: std::time::Duration,
}
#[cfg(any(test, not(debug_assertions)))]
impl RotatingLogWriter {
fn new(
logs_dir: std::path::PathBuf,
@ -120,7 +113,6 @@ impl RotatingLogWriter {
}
}
#[cfg(any(test, not(debug_assertions)))]
impl RotatingLogState {
fn write_event(
&mut self,
@ -157,7 +149,6 @@ impl RotatingLogState {
}
}
#[cfg(any(test, not(debug_assertions)))]
fn rotating_log_path(
logs_dir: &std::path::Path,
session_name: &str,
@ -170,7 +161,6 @@ fn rotating_log_path(
}
}
#[cfg(any(test, not(debug_assertions)))]
fn open_log_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
@ -178,7 +168,6 @@ fn open_log_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
.open(path)
}
#[cfg(any(test, not(debug_assertions)))]
fn cleanup_launcher_logs(
logs_dir: &std::path::Path,
max_files: usize,
@ -194,7 +183,6 @@ fn cleanup_launcher_logs(
);
}
#[cfg(any(test, not(debug_assertions)))]
fn cleanup_launcher_logs_at(
logs_dir: &std::path::Path,
max_files: usize,
@ -244,7 +232,6 @@ fn cleanup_launcher_logs_at(
}
}
#[cfg(any(test, not(debug_assertions)))]
fn launcher_log_is_expired(
created: std::time::SystemTime,
now: std::time::SystemTime,
@ -253,14 +240,12 @@ fn launcher_log_is_expired(
now.duration_since(created).is_ok_and(|age| age > max_age)
}
#[cfg(any(test, not(debug_assertions)))]
struct LogEventWriter {
writer: RotatingLogWriter,
buffer: Vec<u8>,
max_file_bytes: u64,
}
#[cfg(any(test, not(debug_assertions)))]
impl LogEventWriter {
fn commit(&mut self) -> std::io::Result<()> {
if self.buffer.is_empty() {
@ -272,7 +257,6 @@ impl LogEventWriter {
}
}
#[cfg(any(test, not(debug_assertions)))]
impl std::io::Write for LogEventWriter {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
self.buffer.extend_from_slice(buffer);
@ -285,14 +269,12 @@ impl std::io::Write for LogEventWriter {
}
}
#[cfg(any(test, not(debug_assertions)))]
impl Drop for LogEventWriter {
fn drop(&mut self) {
let _ = self.commit();
}
}
#[cfg(any(test, not(debug_assertions)))]
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingLogWriter {
type Writer = LogEventWriter;
@ -314,7 +296,6 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingLogWriter {
}
}
#[cfg(any(test, not(debug_assertions)))]
impl RotatingLogWriter {
fn event_writer(&self, max_file_bytes: u64) -> LogEventWriter {
LogEventWriter {
@ -627,14 +608,35 @@ impl std::io::Write for TruncatedConsoleWriter {
}
}
// Handling for the live development logging
// This will log to the console, and will not log to a file
// Handling for live development logging. Keep the compact console output, but
// also write the full event stream so exported error reports contain the
// session that produced the error.
#[cfg(debug_assertions)]
pub fn start_logger(app_identifier: &str) -> Option<()> {
use crate::prelude::DirectoryInfo;
use chrono::Local;
use tracing_subscriber::fmt::time::ChronoLocal;
use tracing_subscriber::prelude::*;
let logs_dir = DirectoryInfo::launcher_logs_dir_path(app_identifier)
.or_else(|| {
eprintln!("Could not resolve launcher logs directory");
None
})?;
let session_name =
format!("session_{}", Local::now().format("%Y%m%d_%H%M%S"));
let file_writer = RotatingLogWriter::new(
logs_dir,
session_name,
LAUNCHER_LOG_MAX_BYTES,
LAUNCHER_WARN_ERROR_MAX_BYTES,
LAUNCHER_LOG_MAX_FILES,
LAUNCHER_LOG_MAX_AGE,
)
.map_err(|error| {
eprintln!("Could not start launcher log writer: {error}");
error
})
.ok()?;
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
@ -643,39 +645,18 @@ pub fn start_logger(app_identifier: &str) -> Option<()> {
.add_directive("hyper=info".parse().ok()?)
.add_directive("hyper_util=info".parse().ok()?)
.add_directive("sqlx=warn".parse().ok()?);
let console_layer = tracing_subscriber::fmt::layer().with_writer(|| {
TruncatedConsoleWriter {
stdout: std::io::stdout(),
}
});
// Debug builds historically only logged to the console, so a launched
// GUI process left no trace once its window closed. Tee into the same
// launcher_logs directory the release build uses so debug launches can
// be inspected after the fact.
let file_layer = DirectoryInfo::launcher_logs_dir_path(app_identifier)
.and_then(|dir| {
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join(format!(
"session_debug_{}.log",
Local::now().format("%Y%m%d_%H%M%S")
));
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.ok()
})
.map(|file| {
tracing_subscriber::fmt::layer()
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
.with_timer(ChronoLocal::rfc_3339())
});
tracing_subscriber::registry()
.with(console_layer)
.with(file_layer)
.with(tracing_subscriber::fmt::layer().with_writer(|| {
TruncatedConsoleWriter {
stdout: std::io::stdout(),
}
}))
.with(
tracing_subscriber::fmt::layer()
.with_writer(file_writer)
.with_ansi(false)
.with_timer(ChronoLocal::rfc_3339()),
)
.with(filter)
.with(tracing_error::ErrorLayer::default())
.init();

View File

@ -515,19 +515,18 @@ fn app_db_backup_dir_for(db_path: &Path) -> crate::Result<PathBuf> {
))
})?;
let backup_dir = base.join("Backups").join("app-db");
match db_path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
{
Some("beta") | Some("release") => Ok(backup_dir.join(
db_path
.parent()
.and_then(Path::file_name)
.expect("database channel directory has a name"),
)),
_ => Ok(backup_dir),
Ok(default_app_db_backup_dir(base))
}
fn default_app_db_backup_dir(database_dir: &Path) -> PathBuf {
match database_dir.file_name().and_then(|name| name.to_str()) {
Some(channel @ ("beta" | "release")) => database_dir
.parent()
.unwrap_or_else(|| Path::new(""))
.join("Backups")
.join("app-db")
.join(channel),
_ => database_dir.join("Backups").join("app-db"),
}
}
@ -653,6 +652,21 @@ async fn create_sqlite_snapshot(
mod tests {
use super::*;
#[test]
fn recovery_and_update_share_the_channel_backup_directory() {
for channel in ["release", "beta"] {
let settings = Path::new("launcher-settings");
assert_eq!(
default_app_db_backup_dir(&settings.join(channel)),
settings.join("Backups").join("app-db").join(channel)
);
}
assert_eq!(
default_app_db_backup_dir(Path::new("legacy-settings")),
Path::new("legacy-settings").join("Backups").join("app-db")
);
}
async fn create_test_app_db(path: &Path, marker: &str) {
let options = SqliteConnectOptions::new()
.filename(path)

View File

@ -54,6 +54,7 @@ pub struct EditInstance {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct InstanceLaunchOverridesPatch {
pub player: Option<crate::state::InstancePlayer>,
pub instance_mode: Option<crate::state::InstanceMode>,
#[serde(
default,
@ -373,6 +374,9 @@ fn apply_launch_overrides_patch(
if let Some(mode) = patch.instance_mode {
overrides.instance_mode = Some(mode);
}
if let Some(player) = patch.player {
overrides.player = Some(player);
}
if let Some(timeout) = patch.launch_preparation_timeout {
overrides.launch_preparation_timeout = timeout;
}

View File

@ -32,12 +32,17 @@ pub(crate) async fn remove_instance(
.game_dir_override
.as_deref()
.map(PathBuf::from)
.filter(|path| is_version_isolated_game_dir(path))
.filter(|path| {
// Delete the external game directory when the instance owns it.
// A version-isolated `versions/<name>` folder is obviously
// exclusive; a plain `<root>/<pack name>` folder is also owned by
// this instance. A shared `.minecraft` root, however, holds the
// game's libraries/assets and must never be deleted with one
// instance.
is_version_isolated_game_dir(path)
|| !is_shared_minecraft_root(path)
})
{
// New instances created against a configured `.minecraft` root use
// a private `versions/<name>` directory. Remove that external
// directory when the instance is deleted, while preserving shared
// (non-isolated) overrides for backwards compatibility.
game_dir_override
} else {
state.directories.instances_dir().join(&instance.path)
@ -69,3 +74,12 @@ fn is_version_isolated_game_dir(path: &Path) -> bool {
.and_then(|name| name.to_str())
== Some("versions")
}
/// Heuristic: a shared `.minecraft` root holds the game's libraries and
/// assets, which must survive the removal of any single instance that points
/// at it. Instance-owned external folders (e.g. a hosted modpack's
/// `<root>/<pack name>` directory) contain only mods/saves/config and no such
/// shared game body.
fn is_shared_minecraft_root(path: &Path) -> bool {
path.join("libraries").is_dir() || path.join("assets").is_dir()
}

View File

@ -14,8 +14,19 @@ pub enum InstanceMode {
Local,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InstancePlayer {
pub id: uuid::Uuid,
pub name: String,
pub account_type: crate::state::MinecraftAccountType,
#[serde(default)]
pub skin_site_user: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InstanceLaunchOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player: Option<InstancePlayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_mode: Option<InstanceMode>,
pub instance_id: String,
@ -34,6 +45,7 @@ pub struct InstanceLaunchOverrides {
impl InstanceLaunchOverrides {
pub fn empty(instance_id: String) -> Self {
Self {
player: None,
instance_mode: None,
instance_id,
java_path: None,
@ -55,6 +67,8 @@ impl InstanceLaunchOverrides {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct InstanceLaunchOverridesData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player: Option<InstancePlayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_mode: Option<InstanceMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -83,6 +97,7 @@ impl InstanceLaunchOverridesData {
instance_id: String,
) -> InstanceLaunchOverrides {
InstanceLaunchOverrides {
player: self.player,
instance_mode: self.instance_mode,
instance_id,
java_path: self.java_path,
@ -101,6 +116,7 @@ impl InstanceLaunchOverridesData {
impl From<&InstanceLaunchOverrides> for InstanceLaunchOverridesData {
fn from(overrides: &InstanceLaunchOverrides) -> Self {
Self {
player: overrides.player.clone(),
instance_mode: overrides.instance_mode,
java_path: overrides.java_path.clone(),
extra_launch_args: overrides.extra_launch_args.clone(),
@ -154,4 +170,35 @@ mod tests {
);
assert!(serde_json::from_str::<InstanceMode>("\"invalid\"").is_err());
}
#[test]
fn instance_player_survives_configuration_round_trip() {
let mut original = InstanceLaunchOverrides::empty("one".into());
let id = uuid::Uuid::new_v4();
original.player = Some(InstancePlayer {
id,
name: "SavedPlayer".into(),
account_type: crate::state::MinecraftAccountType::Yggdrasil,
skin_site_user: Some("owner".into()),
});
let encoded = serde_json::to_string(
&InstanceLaunchOverridesData::from(&original),
)
.unwrap();
let decoded: InstanceLaunchOverridesData =
serde_json::from_str(&encoded).unwrap();
let saved = decoded.into_launch_overrides("one".into()).player.unwrap();
assert_eq!(saved.id, id);
assert_eq!(saved.skin_site_user.as_deref(), Some("owner"));
assert_eq!(
saved.account_type,
crate::state::MinecraftAccountType::Yggdrasil
);
assert!(
serde_json::from_str::<InstanceLaunchOverridesData>("{}")
.unwrap()
.player
.is_none()
);
}
}

View File

@ -611,6 +611,7 @@ where
}
let launch_overrides = InstanceLaunchOverrides {
player: None,
instance_mode: None,
instance_id: instance_id.clone(),
java_path: input.java_path,

View File

@ -798,6 +798,39 @@ impl Credentials {
Self::get_active_with_refresh(exec, true).await
}
pub async fn for_instance_player(
player: &crate::state::InstancePlayer,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Self> {
let accounts = Self::get_all_without_refresh(exec).await?;
let mut account = accounts
.remove(&player.id)
.map(|(_, account)| account)
.ok_or_else(|| {
ErrorKind::InputError(format!(
"实例玩家 {} 已退出登录,请重新登录该玩家或在实例设置中切换",
player.name
))
.as_error()
})?;
if account.account_type != player.account_type
|| player.skin_site_user.as_ref().is_some_and(|user| {
account.yggdrasil.as_ref().is_none_or(|ygg| {
ygg.login != *user
|| ygg.api_root
!= "https://skin.starlight.cool/yggdrasil"
})
})
{
return Err(ErrorKind::InputError(
"实例玩家身份不匹配,请在实例设置中重新选择".into(),
)
.as_error());
}
account.refresh(exec).await?;
Ok(account)
}
pub async fn get_active_without_refresh(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Option<Self>> {
@ -900,6 +933,21 @@ impl Credentials {
pub async fn upsert(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<()> {
self.upsert_inner(exec, false).await
}
pub(crate) async fn upsert_preserving_selection(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<()> {
self.upsert_inner(exec, true).await
}
async fn upsert_inner(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
preserve_selection: bool,
) -> crate::Result<()> {
let profile = self.maybe_online_profile().await;
let expires = self.expires.timestamp();
@ -922,7 +970,7 @@ impl Credentials {
.as_ref()
.map_or("", |account| account.client_token.as_str());
if self.active {
if self.active && !preserve_selection {
sqlx::query!(
"
UPDATE minecraft_users
@ -943,7 +991,7 @@ impl Credentials {
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (uuid) DO UPDATE SET
active = $2,
active = CASE WHEN $12 THEN minecraft_users.active ELSE $2 END,
username = $3,
account_type = $4,
access_token = $5,
@ -956,7 +1004,7 @@ impl Credentials {
",
)
.bind(uuid)
.bind(self.active)
.bind(self.active && !preserve_selection)
.bind(&profile.name)
.bind(account_type)
.bind(&self.access_token)
@ -966,6 +1014,7 @@ impl Credentials {
.bind(yggdrasil_server_name)
.bind(yggdrasil_login)
.bind(yggdrasil_client_token)
.bind(preserve_selection)
.execute(exec)
.await?;
@ -1042,6 +1091,75 @@ impl Serialize for Credentials {
mod offline_account_tests {
use super::*;
#[tokio::test]
async fn instance_player_never_falls_back_to_global_account() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let first = Credentials::offline("First").unwrap();
let second = Credentials::offline("Second").unwrap();
first.upsert(&pool).await.unwrap();
let mut refreshed = Credentials::offline("First").unwrap();
refreshed.active = false;
refreshed.upsert_preserving_selection(&pool).await.unwrap();
assert_eq!(
Credentials::get_active_without_refresh(&pool)
.await
.unwrap()
.unwrap()
.offline_profile
.id,
first.offline_profile.id
);
second.upsert(&pool).await.unwrap();
first.upsert_preserving_selection(&pool).await.unwrap();
let binding = crate::state::InstancePlayer {
id: first.offline_profile.id,
name: "First".into(),
account_type: MinecraftAccountType::Offline,
skin_site_user: None,
};
assert_eq!(
Credentials::get_active_without_refresh(&pool)
.await
.unwrap()
.unwrap()
.offline_profile
.id,
second.offline_profile.id
);
assert_eq!(
Credentials::for_instance_player(&binding, &pool)
.await
.unwrap()
.offline_profile
.id,
first.offline_profile.id
);
let mismatched = crate::state::InstancePlayer {
account_type: MinecraftAccountType::Microsoft,
..binding.clone()
};
assert!(
Credentials::for_instance_player(&mismatched, &pool)
.await
.is_err()
);
sqlx::query("DELETE FROM minecraft_users WHERE uuid = ?")
.bind(binding.id.to_string())
.execute(&pool)
.await
.unwrap();
assert!(
Credentials::for_instance_player(&binding, &pool)
.await
.is_err()
);
}
#[test]
fn creates_java_compatible_offline_uuid() {
let credentials = Credentials::offline("Notch").unwrap();

View File

@ -199,7 +199,8 @@ pub async fn begin_yggdrasil_login(
.await?;
// 收集本次登录可用的所有角色;优先使用 availableProfiles回退到 selectedProfile。
let mut profiles: Vec<YggdrasilProfile> = response.available_profiles.clone();
let mut profiles: Vec<YggdrasilProfile> =
response.available_profiles.clone();
if profiles.is_empty() {
if let Some(selected) = response.selected_profile.clone() {
profiles.push(selected);
@ -607,10 +608,122 @@ fn create_credentials(
}
}
pub async fn login_skin_site_player(
token: &str,
player_id: Uuid,
user_id: &str,
exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy,
) -> crate::Result<Credentials> {
#[derive(Deserialize)]
struct Envelope {
payload: SkinSiteLogin,
}
let client_token = Uuid::new_v4().to_string();
let response = reqwest::Client::builder()
.timeout(StdDuration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()?
.post("https://skin.starlight.cool/starlight/launcher/login")
.bearer_auth(token)
.json(&json!({ "playerId": player_id, "clientToken": client_token }))
.send()
.await?;
if !response.status().is_success() {
return Err(ErrorKind::InputError(format!(
"皮肤站玩家登录失败HTTP {}),请检查登录状态后重试",
response.status().as_u16()
))
.as_error());
}
let login = response.json::<Envelope>().await?.payload;
let credential =
skin_site_credentials(login, player_id, user_id, &client_token)?;
credential.upsert_preserving_selection(exec).await?;
Ok(credential)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SkinSiteLogin {
access_token: String,
client_token: String,
selected_profile: YggdrasilProfile,
user_id: String,
}
fn skin_site_credentials(
login: SkinSiteLogin,
player_id: Uuid,
user_id: &str,
client_token: &str,
) -> crate::Result<Credentials> {
if login.selected_profile.id != player_id
|| login.user_id != user_id
|| login.client_token != client_token
|| login.access_token.is_empty()
{
return Err(ErrorKind::InputError(
"皮肤站返回的玩家身份不匹配,请重试".into(),
)
.as_error());
}
let mut credential = create_credentials(
login.selected_profile,
login.access_token,
login.client_token,
YggdrasilMetadata {
api_root: "https://skin.starlight.cool/yggdrasil".into(),
server_name: "StarLight".into(),
raw: String::new(),
},
user_id,
);
credential.active = false;
Ok(credential)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skin_site_login_validates_identity_and_keeps_global_selection() {
let id = Uuid::new_v4();
let response = || SkinSiteLogin {
access_token: "game-token".into(),
client_token: "client".into(),
selected_profile: YggdrasilProfile {
id,
name: "Player".into(),
},
user_id: "owner".into(),
};
let credentials =
skin_site_credentials(response(), id, "owner", "client").unwrap();
assert!(!credentials.active);
assert_eq!(credentials.account_type, MinecraftAccountType::Yggdrasil);
assert_eq!(
credentials.yggdrasil.unwrap().api_root,
"https://skin.starlight.cool/yggdrasil"
);
assert!(
skin_site_credentials(
response(),
Uuid::new_v4(),
"owner",
"client"
)
.is_err()
);
assert!(
skin_site_credentials(response(), id, "other", "client").is_err()
);
assert!(
skin_site_credentials(response(), id, "owner", "other-client")
.is_err()
);
}
#[test]
fn normalizes_api_roots() {
assert_eq!(

View File

@ -616,220 +616,62 @@ impl Process {
let mut buf_reader = BufReader::new(reader);
if xml_logging {
let mut reader = Reader::from_reader(buf_reader);
reader.config_mut().enable_all_checks(false);
let mut buf = Vec::new();
let mut current_event = Log4jEvent::default();
let mut in_event = false;
let mut in_message = false;
let mut in_throwable = false;
let mut current_content = String::new();
// NOTE: we deliberately do NOT use quick-xml's streaming async reader
// here. Its parser marks itself `ParseState::Done` permanently after
// any I/O/parse error or a transient `Eof` (see quick-xml #513), so a
// single split XML frame on the live pipe would silently kill all
// further log forwarding — which is exactly the "logs stop after the
// client finished starting" bug.
//
// Instead we accumulate raw bytes into a buffer and cut out complete
// `<log4j:Event ...>…</log4j:Event>` frames, parsing each frame in one
// synchronous pass. Malformed or partial frames are skipped without
// poisoning the stream, so forwarding always continues.
let mut pending = String::new();
let mut chunk = [0u8; 8192];
loop {
match reader.read_event_into_async(&mut buf).await {
let read = match tokio::io::AsyncReadExt::read(&mut buf_reader, &mut chunk).await {
Ok(0) => break,
Ok(n) => n,
Err(e) => {
tracing::error!(
"Error at position {}: {:?}",
reader.buffer_position(),
e
);
tracing::warn!("Live log read error: {e}");
break;
}
// exits the loop when reaching end of file
Ok(Event::Eof) => break,
};
Ok(Event::Start(e)) => {
match e.name().as_ref() {
b"log4j:Event" => {
// Reset for new event
current_event = Log4jEvent::default();
in_event = true;
pending.push_str(&String::from_utf8_lossy(&chunk[..read]));
// Extract attributes
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(
attr.key.into_inner(),
)
.to_string();
let value =
String::from_utf8_lossy(&attr.value)
.to_string();
match key.as_str() {
"logger" => {
current_event.logger_name =
Some(value)
}
"level" => {
current_event.level = Some(value)
}
"thread" => {
current_event.thread_name =
Some(value)
}
"timestamp" => {
current_event.timestamp_millis =
value.parse::<i64>().ok()
}
_ => {}
}
}
}
b"log4j:Message" => {
in_message = true;
current_content = String::new();
}
b"log4j:Throwable" => {
in_throwable = true;
current_content = String::new();
}
_ => {}
}
}
Ok(Event::End(e)) => {
match e.name().as_ref() {
b"log4j:Message" => {
in_message = false;
current_event.message =
Some(current_content.clone());
}
b"log4j:Throwable" => {
in_throwable = false;
current_event.throwable =
if current_content.is_empty() {
None
} else {
Some(current_content.clone())
};
// Write log entry + throwable to file
if let Some(formatted_log) =
Self::format_log4j_entry(&current_event)
{
if let Err(e) = Process::append_to_log_file(
&log_path,
&formatted_log,
) {
tracing::error!(
"Failed to write to log file: {}",
e
);
}
if let Some(ref throwable) =
current_event.throwable
&& let Err(e) =
Process::append_to_log_file(
&log_path, throwable,
)
{
tracing::error!(
"Failed to write throwable to log file: {}",
e
);
}
}
Self::emit_log4j_event(
instance_id,
&current_event,
);
}
b"log4j:Event" => {
in_event = false;
// If no throwable was present, write the log entry at the end of the event
if current_event.message.is_some()
&& current_event.throwable.is_none()
{
if let Some(formatted_log) =
Self::format_log4j_entry(&current_event)
&& let Err(e) =
Process::append_to_log_file(
&log_path,
&formatted_log,
)
{
tracing::error!(
"Failed to write to log file: {}",
e
);
}
if let Some(timestamp_millis) =
current_event.timestamp_millis
{
let timestamp =
timestamp_millis.to_string();
let message = current_event
.message
.as_deref()
.unwrap_or("")
.trim();
crate::api::multiplayer::observe_minecraft_log(
instance_id,
instance_name,
process_id,
message,
)
.await;
if let Err(e) = Self::maybe_handle_server_join_logging(
instance_id,
&timestamp,
message,
).await {
tracing::error!("Failed to handle server join logging: {e}");
}
}
Self::emit_log4j_event(
instance_id,
&current_event,
);
}
}
_ => {}
}
}
Ok(Event::Text(mut e)) => {
if in_message || in_throwable {
if let Ok(text) = e.xml_content() {
append_bounded_log4j_content(
&mut current_content,
&text,
);
}
} else if !in_event
&& !e.inplace_trim_end()
&& !e.inplace_trim_start()
&& let Ok(text) = e.xml_content()
{
if let Err(e) = Process::append_to_log_file(
&log_path,
&format!("{text}\n"),
) {
tracing::error!(
"Failed to write to log file: {}",
e
);
}
Self::emit_legacy_log(instance_id, &text);
}
}
Ok(Event::CData(e)) => {
if (in_message || in_throwable)
&& let Ok(text) = e.xml_content()
{
append_bounded_log4j_content(
&mut current_content,
&text,
);
}
}
_ => (),
// Drain every complete frame currently buffered.
while let Some(frame) = take_next_log4j_frame(&mut pending) {
Self::handle_log4j_frame(
instance_id,
instance_name,
process_id,
&log_path,
&frame,
)
.await;
}
buf.clear();
// Guard against a runaway buffer if no frame delimiters ever
// appear (e.g. raw non-XML output on a logging-configured
// instance). Flush it as legacy text so it is not lost.
if pending.len() > MAX_PERSISTED_LOG_LINE_BYTES {
let text = std::mem::take(&mut pending);
if let Err(e) = Self::append_to_log_file(&log_path, &text) {
tracing::warn!("Failed to write to log file: {e}");
}
Self::emit_legacy_log(instance_id, text.trim_end());
}
}
// Flush any trailing partial content on stream end.
if !pending.trim().is_empty() {
if let Err(e) = Self::append_to_log_file(&log_path, &pending) {
tracing::warn!("Failed to write to log file: {e}");
}
Self::emit_legacy_log(instance_id, pending.trim_end());
}
} else {
while let Ok(Some(line)) =
@ -862,6 +704,164 @@ impl Process {
}
}
/// Parses one complete `<log4j:Event …>…</log4j:Event>` frame and forwards
/// its content to the log file / frontend. A frame that fails to parse is
/// logged and dropped; it never stops the reader loop.
async fn handle_log4j_frame(
instance_id: &str,
instance_name: &str,
process_id: &str,
log_path: &Path,
frame: &str,
) {
let mut reader = Reader::from_str(frame);
reader.config_mut().enable_all_checks(false);
let mut current_event = Log4jEvent::default();
let mut in_message = false;
let mut in_throwable = false;
let mut current_content = String::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Err(e) => {
tracing::warn!("Malformed live log frame: {e}");
break;
}
Ok(Event::Eof) => break,
Ok(Event::Start(e)) => match e.name().as_ref() {
b"log4j:Event" => {
current_event = Log4jEvent::default();
for attr in e.attributes().flatten() {
let key =
String::from_utf8_lossy(attr.key.into_inner())
.to_string();
let value = String::from_utf8_lossy(&attr.value)
.to_string();
match key.as_str() {
"logger" => {
current_event.logger_name = Some(value)
}
"level" => current_event.level = Some(value),
"thread" => {
current_event.thread_name = Some(value)
}
"timestamp" => {
current_event.timestamp_millis =
value.parse::<i64>().ok()
}
_ => {}
}
}
}
b"log4j:Message" => {
in_message = true;
current_content = String::new();
}
b"log4j:Throwable" => {
in_throwable = true;
current_content = String::new();
}
_ => {}
},
Ok(Event::End(e)) => match e.name().as_ref() {
b"log4j:Message" => {
in_message = false;
current_event.message = Some(current_content.clone());
}
b"log4j:Throwable" => {
in_throwable = false;
current_event.throwable =
if current_content.is_empty() {
None
} else {
Some(current_content.clone())
};
}
b"log4j:Event" => {
if let Some(formatted) =
Self::format_log4j_entry(&current_event)
{
if let Err(e) =
Self::append_to_log_file(log_path, &formatted)
{
tracing::error!(
"Failed to write to log file: {e}"
);
}
if let Some(ref throwable) = current_event.throwable
&& let Err(e) = Self::append_to_log_file(
log_path,
throwable,
)
{
tracing::error!(
"Failed to write throwable to log file: {e}"
);
}
if let Some(timestamp_millis) =
current_event.timestamp_millis
{
let timestamp = timestamp_millis.to_string();
let message = current_event
.message
.as_deref()
.unwrap_or("")
.trim();
crate::api::multiplayer::observe_minecraft_log(
instance_id,
instance_name,
process_id,
message,
)
.await;
if let Err(e) =
Self::maybe_handle_server_join_logging(
instance_id,
&timestamp,
message,
)
.await
{
tracing::error!(
"Failed to handle server join logging: {e}"
);
}
}
Self::emit_log4j_event(instance_id, &current_event);
}
}
_ => {}
},
Ok(Event::Text(e)) => {
if (in_message || in_throwable)
&& let Ok(text) = e.xml_content()
{
append_bounded_log4j_content(
&mut current_content,
&text,
);
}
}
Ok(Event::CData(e)) => {
if (in_message || in_throwable)
&& let Ok(text) = e.xml_content()
{
append_bounded_log4j_content(
&mut current_content,
&text,
);
}
}
_ => (),
}
buf.clear();
}
}
fn format_timestamp(timestamp_millis: Option<i64>) -> String {
if let Some(timestamp_val) = timestamp_millis {
let datetime_utc = if timestamp_val > i32::MAX as i64 {
@ -1267,7 +1267,28 @@ impl Process {
Ok(())
}
}
/// Cuts the next complete `<log4j:Event …>…</log4j:Event>` frame out of the
/// live buffer and returns it as an owned string, leaving any trailing partial
/// frame in place.
///
/// Returns `None` when the buffer does not yet contain a full frame. This is a
/// plain string operation on purpose: it never poisons any parser state, so a
/// split or malformed frame on the live pipe cannot stop log forwarding.
fn take_next_log4j_frame(buffer: &mut String) -> Option<String> {
const OPEN: &str = "<log4j:Event";
const CLOSE: &str = "</log4j:Event>";
let start = buffer.find(OPEN)?;
// Discard anything before the frame (raw text, XML prolog, …).
if start > 0 {
buffer.drain(..start);
}
let close = buffer.find(CLOSE)?;
let end = close + CLOSE.len();
let frame = buffer[..end].to_string();
buffer.drain(..end);
Some(frame)
}
#[cfg(test)]
mod post_upgrade_tests {
use super::*;

View File

@ -166,6 +166,8 @@ pub struct Settings {
pub memory: MemorySettings,
pub force_fullscreen: bool,
pub maximize_window: bool,
#[serde(default)]
pub force_unicode_font: bool,
pub game_resolution: WindowSize,
pub hide_on_process_start: bool,
pub enter_lightweight_mode_on_game_launch: bool,
@ -347,6 +349,11 @@ impl Settings {
},
force_fullscreen: res.mc_force_fullscreen == 1,
maximize_window: res.mc_maximize_window == 1,
force_unicode_font: sqlx::query_scalar(
"SELECT mc_force_unicode_font FROM settings WHERE id = 0",
)
.fetch_one(exec)
.await?,
game_resolution: WindowSize(
res.mc_game_resolution_x as u16,
res.mc_game_resolution_y as u16,
@ -590,6 +597,12 @@ impl Settings {
.bind(self.memory.optimize_before_launch)
.execute(exec)
.await?;
sqlx::query(
"UPDATE settings SET mc_force_unicode_font = ? WHERE id = 0",
)
.bind(self.force_unicode_font)
.execute(exec)
.await?;
Ok(())
}
@ -1092,6 +1105,58 @@ mod tests {
assert!(!reloaded.bypass_curseforge_download_restrictions);
}
#[tokio::test]
async fn unicode_font_defaults_off_after_upgrade_and_round_trips() {
let migrator = sqlx::migrate!();
for previous_schema in [false, true] {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
if previous_schema {
let previous_migrator = sqlx::migrate::Migrator {
migrations: std::borrow::Cow::Owned(
migrator
.iter()
.filter(|migration| {
migration.version < 20260919000000
})
.cloned()
.collect(),
),
..sqlx::migrate::Migrator::DEFAULT
};
previous_migrator.run(&pool).await.unwrap();
sqlx::query(
"UPDATE settings SET locale = 'zh-TW' WHERE id = 0",
)
.execute(&pool)
.await
.unwrap();
}
migrator.run(&pool).await.unwrap();
let mut settings = Settings::get(&pool).await.unwrap();
assert!(!settings.force_unicode_font);
if previous_schema {
assert_eq!(settings.locale, "zh-TW");
}
for enabled in [true, false] {
settings.force_unicode_font = enabled;
settings.update(&pool).await.unwrap();
let reloaded = Settings::get(&pool).await.unwrap();
assert_eq!(reloaded.force_unicode_font, enabled);
}
// Older clients and serialized settings omit the newly added field.
let mut legacy = serde_json::to_value(&settings).unwrap();
legacy.as_object_mut().unwrap().remove("force_unicode_font");
let restored: Settings = serde_json::from_value(legacy).unwrap();
assert!(!restored.force_unicode_font);
}
}
#[tokio::test]
async fn memory_optimization_round_trips_in_a_fresh_database() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()

View File

@ -105,7 +105,11 @@ const REASSIGNABLE_FIRST_BYTE_TIMEOUT: time::Duration =
const MAX_DOWNLOAD_ATTEMPT_HISTORY: usize = 12;
const MAX_DOWNLOAD_DIAGNOSTIC_BYTES: usize = 8 * 1024;
const MAX_FAILURE_COOLDOWN: time::Duration = time::Duration::from_secs(1);
const H2_FALLBACK_TTL: time::Duration = MAX_FAILURE_COOLDOWN;
// Once an authority has returned a broken HTTP/2 body, keep subsequent file
// retries on HTTP/1.1 for the rest of a typical install. A one-second memory
// allowed longer batch downloads to fall straight back onto the same bad H2
// connection between retry rounds.
const H2_FALLBACK_TTL: time::Duration = time::Duration::from_secs(10 * 60);
const TASK_PROBE_MAX_ROUTES: usize = 3;
const MAX_TASK_PROBE_STATES: usize = 64;
#[cfg(not(test))]
@ -6495,7 +6499,10 @@ async fn download_to_path_inner(
Ok(chunk) => chunk,
Err(error) => {
let decode_failure = error.is_decode();
if is_h2_protocol_failure(&error)
if (is_h2_protocol_failure(&error)
|| (decode_failure
&& http_version
== reqwest::Version::HTTP_2))
&& let Some(authority) =
url_authority(&final_url)
{

View File

@ -19,32 +19,30 @@
/>
</div>
<div
v-else
class="log-viewport-spacer relative w-full min-w-max"
:style="{ height: totalHeight + 'px' }"
>
<!--
Native virtualization: every line is rendered, but `content-visibility:
auto` lets the browser skip layout/paint for off-screen lines, while
`contain-intrinsic-size` gives the skipped elements a placeholder size
so the scrollbar stays stable. This avoids the manual height estimation
that used to make tall (wrapped / highlighted) lines overlap.
-->
<div v-else class="log-viewport-spacer relative w-full min-w-max">
<div
class="absolute inset-x-0 top-0"
:style="{ transform: 'translateY(' + topOffset + 'px)' }"
v-for="item in lines"
:key="item.originalIndex"
:data-line="item.originalIndex + 1"
class="log-line log-line-cv flex items-stretch whitespace-pre"
:class="entryClass(item.line)"
:style="lineStyle"
>
<div
v-for="item in windowItems"
:key="item.originalIndex"
:data-line="item.originalIndex + 1"
class="log-line flex items-stretch whitespace-pre"
:class="entryClass(item.line)"
:style="{ height: estimateHeight(item) + 'px' }"
<span
class="flex shrink-0 w-[52px] items-center justify-end leading-none text-right text-secondary bg-surface-3 border-r border-solid border-surface-3 select-none overflow-hidden"
>{{ item.originalIndex + 1 }}</span
>
<span
class="flex shrink-0 w-[52px] items-center justify-end leading-none text-right text-secondary bg-surface-3 border-r border-solid border-surface-3 select-none overflow-hidden"
>{{ item.originalIndex + 1 }}</span
>
<span
class="log-line-content flex-1 px-2 break-all [overflow-wrap:anywhere]"
v-html="renderLine(item)"
></span>
</div>
<span
class="log-line-content flex-1 px-2 break-all [overflow-wrap:anywhere]"
v-html="renderLine(item)"
></span>
</div>
</div>
@ -99,114 +97,21 @@ const props = withDefaults(
)
const viewportRef = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
const stickToBottom = ref(true)
// 行高:单行 = 字号 × 1.4与等宽字体匹配wrap 时按估算折行数放大
const lineHeightPx = computed(() => Math.round(props.fontSize * 1.4))
// wrap 折行估算0.6em 为等宽字符平均宽,乘 0.9 留保守余量(行高宁高勿矮,避免内容溢出重叠)
const charsPerLine = computed(() => {
const vp = viewportRef.value
if (!vp) return 120
return Math.max(20, Math.floor((vp.clientWidth / (props.fontSize * 0.6)) * 0.9))
// Placeholder row height for `contain-intrinsic-size`. Native
// `content-visibility: auto` replaces this with the real measured height once a
// line enters the viewport, so it only needs to be a reasonable estimate to
// keep the scrollbar from jumping. Wrapped lines can be taller, so bias higher.
const intrinsicLineHeight = computed(() => {
const single = Math.round(props.fontSize * 1.4)
return props.wrap ? single * 2 : single
})
function estimateHeight(item: ViewportLine): number {
if (!props.wrap) return lineHeightPx.value
const lines = Math.max(1, Math.ceil(item.line.text.length / charsPerLine.value))
return lines * lineHeightPx.value
}
// 高度前缀和缓存lines/wrap/fontSize 变化时重建O(n)滚动时二分查找O(log n)
// 总高度必须是响应式的:普通变量 + 无依赖 computed 会缓存过期值,
// 清空控制台后模板不再读取它,重启后 spacer 会以旧高度渲染(底部空白)。
let heightPrefix: number[] | null = null
const heightTotal = ref(0)
function rebuildHeights() {
const n = props.lines.length
if (!props.wrap) {
heightPrefix = null
heightTotal.value = n * lineHeightPx.value
return
}
const prefix = new Array<number>(n)
let acc = 0
for (let i = 0; i < n; i++) {
prefix[i] = acc
acc += estimateHeight(props.lines[i]!)
}
heightPrefix = prefix
heightTotal.value = acc
}
watch(
() => [props.lines, props.wrap, props.fontSize] as const,
([lines], previous) => {
rebuildHeights()
// A fresh stream after an empty console (clear, restart, initial
// hydration) always resumes bottom-following.
if (previous && previous[0].length === 0 && lines.length > 0) {
stickToBottom.value = true
}
if (lines.length === 0) {
// Reset the virtual window state along with the DOM scroll position;
// browsers may clamp silently without firing a scroll event.
scrollTop.value = 0
if (viewportRef.value) viewportRef.value.scrollTop = 0
}
if (stickToBottom.value) {
nextTick(scrollToBottom)
}
},
{ immediate: true },
)
const totalHeight = computed(() => heightTotal.value)
// 虚拟窗口:可见行 + 上下缓冲
const WINDOW_BUFFER = 15
function computeWindow(): { items: ViewportLine[]; startIndex: number } {
const n = props.lines.length
if (n === 0) return { items: [], startIndex: 0 }
let start = 0
let end = n - 1
if (n > WINDOW_BUFFER * 2) {
if (props.wrap && heightPrefix) {
let lo = 0
let hi = n - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (heightPrefix[mid]! <= scrollTop.value) lo = mid
else hi = mid - 1
}
start = Math.max(0, lo - WINDOW_BUFFER)
} else {
const first = Math.floor(scrollTop.value / lineHeightPx.value)
start = Math.max(0, first - WINDOW_BUFFER)
}
end = Math.min(
n - 1,
start + Math.ceil(viewportHeight.value / lineHeightPx.value) + WINDOW_BUFFER * 2,
)
}
return { items: props.lines.slice(start, end + 1), startIndex: start }
}
const windowState = computed(computeWindow)
const windowItems = computed(() => windowState.value.items)
const topOffset = computed(() => {
const { startIndex } = windowState.value
if (startIndex === 0) return 0
if (props.wrap && heightPrefix) return heightPrefix[startIndex]!
return startIndex * lineHeightPx.value
})
const lineStyle = computed(() => ({
'content-visibility': 'auto',
'contain-intrinsic-size': `auto ${intrinsicLineHeight.value}px`,
}))
function entryClass(line: LogLine): string {
if (line.level === 'error') return 'entry-error'
@ -235,43 +140,47 @@ function renderLine(item: ViewportLine): string {
function handleScroll() {
const vp = viewportRef.value
if (!vp) return
scrollTop.value = vp.scrollTop
viewportHeight.value = vp.clientHeight
stickToBottom.value = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - lineHeightPx.value * 2
stickToBottom.value = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 32
}
function scrollToBottom() {
const vp = viewportRef.value
if (!vp) return
vp.scrollTop = vp.scrollHeight
scrollTop.value = vp.scrollTop
stickToBottom.value = true
}
function syncViewportSize() {
const vp = viewportRef.value
if (!vp) return
viewportHeight.value = vp.clientHeight
// 窗口宽度影响 wrap 折行估算resize 时重建高度缓存
if (props.wrap) rebuildHeights()
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
syncViewportSize()
if (stickToBottom.value) nextTick(scrollToBottom)
resizeObserver = new ResizeObserver(syncViewportSize)
resizeObserver = new ResizeObserver(() => {
if (stickToBottom.value) scrollToBottom()
})
if (viewportRef.value) resizeObserver.observe(viewportRef.value)
window.addEventListener('resize', syncViewportSize)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
resizeObserver = null
window.removeEventListener('resize', syncViewportSize)
})
// Follow the tail while new lines stream in, but only when the user has not
// scrolled up. A fresh stream after an empty console (clear, restart, initial
// hydration) always resumes bottom-following.
watch(
() => props.lines,
(lines, previous) => {
if (previous && previous.length === 0 && lines.length > 0) {
stickToBottom.value = true
}
if (stickToBottom.value) {
nextTick(scrollToBottom)
}
},
{ immediate: true },
)
defineExpose({
scrollToBottom,
})

775
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -62,11 +62,42 @@ for (const target of targets) {
}
}
function digest(asset) {
if (typeof asset.digest !== 'string' || !asset.digest.startsWith('sha256:')) {
throw new Error(`Release asset ${asset.name} has no SHA-256 digest`)
}
return asset.digest.slice('sha256:'.length)
}
const apt = {}
for (const target of [
{ platform: 'linux-x86_64', assetSuffix: '_amd64.deb' },
{ platform: 'linux-aarch64', assetSuffix: '_arm64.deb' },
]) {
const matches = assets.filter((asset) => asset.name?.endsWith(target.assetSuffix))
if (matches.length !== 1) {
throw new Error(
`Expected one release asset ending in ${target.assetSuffix}, found ${matches.length}`,
)
}
const asset = matches[0]
const url = asset.browser_download_url ?? asset.url
if (!url || !Number.isSafeInteger(asset.size) || asset.size <= 0) {
throw new Error(`Release asset ${asset.name} has invalid download metadata`)
}
apt[target.platform] = {
url,
sha256: digest(asset),
size: asset.size,
}
}
const manifest = {
version: tag.replace(/^v/, ''),
notes: release.body ?? '',
pub_date: new Date().toISOString(),
platforms,
apt,
}
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`)

View File

@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
const root = path.resolve(import.meta.dirname, '..', '..')
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'starlight-update-manifest-'))
const releasePath = path.join(directory, 'release.json')
const signaturesPath = path.join(directory, 'signatures')
const outputPath = path.join(directory, 'latest.json')
const tag = 'v1.9.7'
const updaterAssets = [
'Axolotl_Launcher_universal.app.tar.gz',
'Axolotl_Launcher_1.9.7_aarch64.AppImage.tar.gz',
'Axolotl_Launcher_1.9.7_amd64.AppImage.tar.gz',
'Axolotl_Launcher_1.9.7_x64-setup.nsis.zip',
]
const debAssets = [
'Axolotl_Launcher_1.9.7_amd64.deb',
'Axolotl_Launcher_1.9.7_arm64.deb',
]
try {
fs.mkdirSync(signaturesPath)
for (const name of updaterAssets) {
fs.writeFileSync(path.join(signaturesPath, `${name}.sig`), 'signature'.repeat(8))
}
fs.writeFileSync(
releasePath,
JSON.stringify({
body: '测试版本',
assets: [...updaterAssets, ...debAssets].map((name, index) => ({
name,
size: index + 1024,
digest: `sha256:${crypto.createHash('sha256').update(name).digest('hex')}`,
browser_download_url: `https://github.com/Mystic-Stars/Axolotl/releases/download/${tag}/${name}`,
})),
}),
)
const create = spawnSync(
process.execPath,
[
'scripts/axolotl/create-update-manifest.mjs',
releasePath,
signaturesPath,
tag,
outputPath,
],
{ cwd: root, encoding: 'utf8' },
)
assert.equal(create.status, 0, create.stderr)
const manifest = JSON.parse(fs.readFileSync(outputPath, 'utf8'))
assert.equal(manifest.version, '1.9.7')
assert.deepEqual(Object.keys(manifest.apt).sort(), ['linux-aarch64', 'linux-x86_64'])
assert.equal(manifest.apt['linux-x86_64'].size, 1028)
assert.match(manifest.apt['linux-aarch64'].sha256, /^[0-9a-f]{64}$/)
const verify = spawnSync(
process.execPath,
['scripts/axolotl/verify-update-manifest.mjs', outputPath, tag],
{ cwd: root, encoding: 'utf8' },
)
assert.equal(verify.status, 0, verify.stderr)
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}

View File

@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { runInNewContext } from 'node:vm'
const source = readFileSync(
new URL('../../apps/app/src/skin_editor_bridge.js', import.meta.url),
'utf8',
)
function createFrame(url = 'http://axolotl-skin.localhost/index.html?embed=skin') {
const listeners = new Map()
const messages = []
const window = {
parent: { postMessage: (message) => messages.push(message) },
addEventListener: (type, handler) => listeners.set(type, handler),
}
runInNewContext(source, { window, location: new URL(url), URLSearchParams })
return { window, listeners, messages }
}
test('reports startup exceptions to the launcher', () => {
const frame = createFrame()
frame.listeners.get('error')({ message: 'ReferenceError: missing editor dependency' })
assert.equal(frame.messages[0].type, 'axolotl-skin-load-error')
assert.match(frame.messages[0].error, /missing editor dependency/)
})
test('reports rejected module imports even when the editor handles the rejection', async () => {
const frame = createFrame()
frame.window.blockbenchBundleReady = Promise.reject(new Error('Failed to fetch editor module'))
frame.listeners.get('DOMContentLoaded')()
await Promise.resolve()
assert.equal(frame.messages[0].error, 'Failed to fetch editor module')
})
test('does not install the bridge on unrelated pages', () => {
for (const url of ['https://skin.starlight.cool/', 'http://axolotl-skin.localhost/index.html']) {
assert.equal(createFrame(url).listeners.size, 0)
}
})
test('ignores resize observer notifications', () => {
const frame = createFrame()
frame.listeners.get('error')({
message: 'ResizeObserver loop completed with undelivered notifications.',
})
assert.equal(frame.messages.length, 0)
})

View File

@ -42,4 +42,22 @@ for (const platform of requiredPlatforms) {
}
}
for (const platform of ['linux-aarch64', 'linux-x86_64']) {
const artifact = manifest.apt?.[platform]
if (
!artifact ||
typeof artifact.sha256 !== 'string' ||
!/^[0-9a-f]{64}$/i.test(artifact.sha256) ||
!Number.isSafeInteger(artifact.size) ||
artifact.size <= 0
) {
throw new Error(`Missing Debian update for ${platform}`)
}
const url = new URL(artifact.url)
if (url.protocol !== 'https:') {
throw new Error(`Unexpected Debian update URL for ${platform}: ${artifact.url}`)
}
}
console.log(`Verified signed ${source} updater manifest for ${expectedVersion}`)

View File

@ -56,7 +56,7 @@
管理员在「Mod 管理 → 管理整合包 → 整合包 Mod 标签」选择已有常用标签。标签按现有规则集合匹配 Mod每次启动独立获取最新清单并检查标签文件 SHA-256包标志相同也不跳过这一步。服务端保存内容快照上传新 Mod 不会破坏正在下载的旧快照。设置审计保存于 `launcher_modpack_tag_audit`,快照登记于 `launcher_tagged_mod_object`,文件位于 `launcher-modpacks/tagged-mods`,不得公开挂载。
标签 Mod 优先于整合包中同标识的旧 Mod安装路径为 `mods/{modId}.starlight.jar`。完整识别一个 JAR 声明的所有 Mod部分覆盖多 Mod JAR 时明确报错,不擅自删除其他 Mod。相同标识的旧文件与新文件统一在下载校验完成后通过恢复日志替换。标签取消或 Mod 移出标签后,移除启动器管理的标签文件并恢复仍在整合包清单中的原版;用户其他 Mod 不作为删除目标。CurseForge 引用保存项目、文件及 Mod 身份,清理缓存后仍能使用原下载源恢复。
整合包自带 Mod 是安装基线,之后再校验标签 Mod。标签快照与整合包 JAR 的 SHA-256 相同时保留整合包原文件和原文件名,不重复下载或改名;只有内容确实不同时,标签 Mod 才优先替换同标识的旧 Mod安装路径为 `mods/{modId}.starlight.jar`。完整识别一个 JAR 声明的所有 Mod部分覆盖多 Mod JAR 时明确报错,不擅自删除其他 Mod。相同标识且内容不同的旧文件与新文件统一在下载校验完成后通过恢复日志替换。标签取消或 Mod 移出标签后,移除启动器管理的标签文件并恢复仍在整合包清单中的原版;用户其他 Mod 不作为删除目标。CurseForge 引用保存项目、文件及 Mod 身份,清理缓存后仍能使用原下载源恢复。
标签下载使用启动器有效并发设置,上限 16 个文件,复用现有下载重试、连接管理与完整性校验。独立弹窗逐个显示文件字节进度,下载页同时显示这些任务;所有文件成功下载、替换后才允许启动,任一失败则保留错误并阻止启动。再次尝试会清理上一轮弹窗进度和失败任务。