perf: 优化实例启动性能并修复过渡动画对齐
依赖校验戳缓存(18.6s→0.1s)、并发化库/资产校验、启动计时埋点、过渡动画200ms、窗口客户区精确对齐、遮罩即时显示、debug构建写文件日志。
This commit is contained in:
411
README.md
411
README.md
@ -1,381 +1,98 @@
|
|||||||
<div align="center">
|
# Starlight Launcher — 实例启动性能优化记录
|
||||||
<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** 出现)。
|
用户反馈:点击实例启动后,要等很久才看到游戏窗口。
|
||||||
|
|
||||||
TLSL 的前身可追溯至 2012 年初建立的凋灵服务器(TWS)。经过多次重组与模式调整,于 2014 年 10 月定名为**星光服务器(StarLight-Server,简称 SLS)**。2020 年初,随着运营模式改变与业务扩展,TLSL 正式成立,SLS 成为其主营子项目。
|
经实测定位,问题由多个独立环节叠加造成,本分支逐项修复。
|
||||||
|
|
||||||
如今 TLSL 旗下拥有 Minecraft 服务器 **StarLight-Server(SLS)**、面向 Fabric 端的领地模组 **Enclosure** 等项目,致力于打造一个完善的 Minecraft 交流与开发平台。
|
## 改动清单
|
||||||
|
|
||||||
**Starlight Launcher** 即是面向 **SLS(星光服务器)** 打造的 Minecraft 桌面启动器。
|
### 1. 依赖校验戳缓存(`packages/app-lib/src/launcher/direct_ensure.rs`)
|
||||||
|
|
||||||
</details>
|
**问题**:PCL/HMCL 直连实例每次启动都要对全部库文件与资产对象重算 SHA1。
|
||||||
|
实测某整合包:118 个库 + 3911 个资产对象,合计约 670 MB,机械硬盘顺序读仅
|
||||||
|
27.8 MB/s,串行校验耗时约 **17.7 秒**,占整个启动准备阶段的 97%。
|
||||||
|
|
||||||
---
|
**方案**:新增持久化「已验证」戳缓存,key 为文件绝对路径,value 为
|
||||||
|
`(size, mtime)`。`file_is_current` 先做 stat,命中戳则跳过 SHA1 读取;
|
||||||
|
文件被替换或修改(size/mtime 变化)时自动回退到完整 SHA1 校验,**不弱化
|
||||||
|
损坏检测**。
|
||||||
|
|
||||||
**Starlight Launcher(星光启动器)** 是一款免费、开源、跨平台的 Minecraft Java 版第三方启动器,支持在一个客户端中搜索、安装和更新来自 Modrinth 与 CurseForge 的模组、整合包、资源包和光影,并提供实例管理、多种账户认证与个性化外观。
|
- 缓存文件:`<caches>/linked-verify-stamps.json`(存 Axolotl 自己的缓存目录,
|
||||||
|
不污染直连安装目录)
|
||||||
|
- 首次启动建立缓存仍走完整校验;后续启动(含跨进程重启)直接命中
|
||||||
|
|
||||||
本项目基于 [Modrinth App](https://github.com/modrinth/code) 构建,并在其基础上由 [Axolotl 启动器](https://github.com/Mystic-Stars/Axolotl) 修改而来,移除了不适用于本项目的商业化模块,专注于提供纯净、无广告的桌面启动体验。
|
**效果**:`asset_scan` 15788ms → 62ms,`ensure_deps` 整体 18.6s → 0.1s。
|
||||||
|
|
||||||
_(注:本项目是调用 Modrinth 公开 API 的独立客户端,与 Rinth, Inc. 无任何关联。)_
|
### 2. 库/资产校验并发化(`packages/app-lib/src/launcher/direct_ensure.rs`)
|
||||||
|
|
||||||
## 核心优势
|
在戳缓存基础上,把库扫描与资产扫描从串行 `for` 循环改为
|
||||||
|
`try_for_each_concurrent`(并发度与下载器一致,`task_concurrency_limit * 2`)。
|
||||||
|
首次建缓存时也能吃到并发收益。
|
||||||
|
|
||||||
- **真跨平台体验**:告别繁琐的环境配置,原生支持 Windows、macOS(完美兼容 Intel 与 Apple Silicon)及各类主流 Linux 发行版。
|
> 注:并发对机械硬盘的随机小文件读取提升有限(IOPS 瓶颈),真正的
|
||||||
- **现代化内容生态**:集成 Modrinth 和 CurseForge,可在启动器中一键浏览。游戏实例、整合包、模组、资源包及光影均可一键安装与升级,彻底告别手动管理依赖的痛苦。
|
> 数量级提升来自上面的戳缓存。
|
||||||
- **高度定制化**:无论是主题色调、背景图片,还是离线皮肤,核心功能与视觉展现均由你自由支配。
|
|
||||||
- **All in one 全新体验**:启动器内置 “实验室” 功能,囊括种子地图、投影工坊等海量使用工具,带来全新原生轮椅体验。
|
|
||||||
|
|
||||||
## 下载与安装
|
### 3. 启动阶段计时埋点(`packages/app-lib/src/api/instance/run.rs`、`packages/app-lib/src/launcher/mod.rs`)
|
||||||
|
|
||||||
请前往 [Releases](https://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest) 下载适合你操作系统的最新安装包。
|
在启动链路插入 `[launch-timing]` 前缀的计时日志,覆盖:
|
||||||
已安装的用户每次均可通过内置的 Tauri 签名校验机制,自动在后台完成更新,无需手动下载安装更新。
|
`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** (10/11 x64) | 下载 `.exe` (NSIS) 安装程序 |
|
|
||||||
| **macOS** | 下载 `通用 .dmg` 镜像文件 |
|
|
||||||
| **Linux** (x64) | 提供 `.AppImage`,`.deb`,`.rpm` 多种格式 |
|
|
||||||
|
|
||||||
## 参与项目开发
|
|
||||||
|
|
||||||
Starlight Launcher 的进步离不开社区的反馈与贡献。
|
|
||||||
如果遇到 Bug 或有新的功能点子,欢迎提交 Issue。如需搭建本地开发环境或查阅打包发布规范,请阅读详细的 [贡献指南 (CONTRIBUTING.md)](CONTRIBUTING.md)。
|
|
||||||
参与社区和贡献代码前,也请先阅读[行为准则 (CODE_OF_CONDUCT.md)](CODE_OF_CONDUCT.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🌟 启动过渡动画(本次改动)
|
|
||||||
|
|
||||||
> 本节记录「游戏启动时窗口平滑过渡」功能的完整实现,供后续维护、移植与继续开发使用。
|
|
||||||
> **生效平台:仅 Windows**(其他平台为空实现,行为与改动前一致)。
|
|
||||||
|
|
||||||
## 一、功能目标
|
|
||||||
|
|
||||||
消除「点击启动 → MC 游戏窗口突然弹出 → 把启动器挤走」的割裂感,用一套完整的窗口动画把这段过渡变得顺滑。
|
|
||||||
|
|
||||||
启动一个实例后,会经历以下动画序列:
|
|
||||||
|
|
||||||
```
|
|
||||||
点击「启动实例」
|
|
||||||
↓
|
|
||||||
① 纯色遮罩出现(启动器主题色,无图)—— 立刻盖住后续所有抖动
|
|
||||||
↓
|
|
||||||
② 启动器放大到全屏(0.4s 动画,全程置顶 + 聚焦)
|
|
||||||
↓
|
|
||||||
③ 全屏后,遮罩上渐显 Mojang SVG logo(0.5s)
|
|
||||||
↓
|
|
||||||
④ 等待 MC 游戏窗口出现(最多 60 秒,期间遮罩一直盖着)
|
|
||||||
↓
|
|
||||||
⑤ 缩小前:遮罩背景色 → Mojang 红 (#db1f29) + 渐隐 logo(0.5s)
|
|
||||||
↓
|
|
||||||
⑥ 启动器缩放到 MC 窗口的位置和大小(0.4s)
|
|
||||||
↓
|
|
||||||
⑦ 等待 1 秒(让游戏稳定,遮罩仍在)
|
|
||||||
↓
|
|
||||||
⑧ 整体淡出透明(0.5s)→ 聚焦游戏窗口 → 启动器最小化
|
|
||||||
↓
|
|
||||||
进入轻量模式(隐藏到系统托盘)
|
|
||||||
```
|
|
||||||
|
|
||||||
**设计要点**:
|
用于定位瓶颈,grep `[launch-timing]` 即可。
|
||||||
|
|
||||||
- **遮罩提前**:纯色遮罩在放大**之前**就出现,所以放大和缩小时的窗口抖动都被盖住,用户看不到。
|
### 4. 启动过渡动画优化(`apps/app/src/mc_transition.rs`)
|
||||||
- **缩放与淡出串行**:先缩放完成,再淡出,不再同步进行(早期版本两者同步,观感差)。
|
|
||||||
- **动画放慢**:缩放 0.4s(早期 0.2s 太快),淡出 0.5s。
|
|
||||||
- **修复重开透明**:最小化后重置窗口透明度,并且恢复时用轻量模式**重建全新窗口**,彻底杜绝「重新打开启动器一片透明、需要点击/拖动才恢复」的问题。
|
|
||||||
- **轻量模式接管**:过渡动画结束后,统一进入轻量模式(隐藏到托盘),不再分别判断用户的「轻量模式 / 启动后隐藏」设置,避免冲突。
|
|
||||||
|
|
||||||
## 二、改动文件总览
|
**a. 动画时长**:400ms → 200ms,帧数 50 → 25。缓解卡顿。
|
||||||
|
|
||||||
| 文件 | 改动类型 | 说明 |
|
**b. 窗口对齐修复**:全屏放大与缩回游戏窗口时,左侧留缝、整体偏右。
|
||||||
|---|---|---|
|
根因是无边框窗口的不可见 resize border(实测窗口比目标大 16×9px,
|
||||||
| `apps/app/src/mc_transition.rs` | **新建** | 核心模块(约 470 行),负责窗口查找、四段动画、遮罩/淡出协调 |
|
可见内容从 (8, 4.5) 才开始)。改用 `set_client_rect`,通过
|
||||||
| `apps/app/src/main.rs` | 修改 | 声明模块 + 注册 4 个 Tauri 命令 |
|
`GetWindowRect` / `GetClientRect` 计算边框并补偿,让**可见客户区**精确
|
||||||
| `apps/app/src/lightweight_mode.rs` | 修改 | `launched` 分支接入过渡,并统一进轻量模式 |
|
落在目标矩形。
|
||||||
| `apps/app/Cargo.toml` | 修改 | windows crate 增加 `Win32_Graphics_Gdi` feature |
|
|
||||||
| `apps/app-frontend/src/App.vue` | 修改 | 遮罩三阶段、淡出、透明度重置等前端逻辑 |
|
|
||||||
| `apps/app-frontend/public/mojang-logo.svg` | 新建 | 过渡遮罩上显示的 Mojang logo |
|
|
||||||
| `packages/app-lib/.env` | 新建 | 从 `.env.prod` 复制,提供编译期环境变量(非功能改动) |
|
|
||||||
|
|
||||||
## 三、各文件详细说明
|
**c. 单次 SetWindowPos**:每帧从两次调用(`set_position` + `set_size`)
|
||||||
|
改为单次 `SetWindowPos`,减少重绘。
|
||||||
|
|
||||||
### 1. `apps/app/src/mc_transition.rs`(新建 · 核心)
|
**d. 恢复可见**:动画前先 `show()` + `unminimize()`,避免轻量模式遗留
|
||||||
|
导致遮罩建了却不可见。
|
||||||
|
|
||||||
> 搜索定位关键词:`mc_transition`
|
### 5. 遮罩即时显示(`apps/app/src/lightweight_mode.rs`)
|
||||||
|
|
||||||
**关键常量**:
|
**问题**:点击启动后遮罩要等约 10 秒才出现。
|
||||||
|
|
||||||
| 常量 | 值 | 含义 |
|
**根因**:收到 `launched` 事件后,代码先 `await maximize_minecraft_window`
|
||||||
|---|---|---|
|
(最多轮询 5 秒等游戏窗口),跑完才启动遮罩动画。
|
||||||
| `FIND_RETRIES` | 120 | 等待 MC 窗口的最大轮询次数 |
|
|
||||||
| `FIND_INTERVAL_MS` | 500 | 每次轮询间隔(120 × 500ms = 60 秒) |
|
|
||||||
| `ANIM_DURATION_MS` | 400 | 放大/缩小动画时长(0.4s) |
|
|
||||||
| `ANIM_STEPS` | 50 | 动画帧数(400ms / 50 = 8ms 一帧) |
|
|
||||||
| `FADE_TIMEOUT_MS` | 700 | 等待前端淡出的超时兜底 |
|
|
||||||
|
|
||||||
**静态变量(跨 await 传信号)**:
|
**方案**:把最大化游戏窗口丢到独立任务,遮罩动画立即启动,两者并行。
|
||||||
|
|
||||||
- `FADE_DONE` — 等前端淡出完成
|
### 6. debug 构建写文件日志(`packages/app-lib/src/logger.rs`)
|
||||||
- `COVER_DONE` — 等纯色遮罩出现
|
|
||||||
- `LOGO_IN_DONE` — 等 logo 渐显完成
|
|
||||||
- `LOGO_OUT_DONE` — 等 logo 渐隐 + 变红完成
|
|
||||||
|
|
||||||
**关键函数**:
|
**问题**:debug 构建的 `start_logger` 只输出到控制台、不落盘,GUI 进程
|
||||||
|
关闭后无法回溯日志,导致性能计时无法采集。
|
||||||
| 函数 | 关键词 | 作用 |
|
|
||||||
|---|---|---|
|
|
||||||
| `pub async fn run(app, pid, maximize)` | `run() invoked` | 主入口,串联全部 8 步 |
|
|
||||||
| `async fn animate_resize(...)` | `animate_resize` | 分帧移动 + 缩放窗口 |
|
|
||||||
| `fn ease_in_out(t)` | `ease_in_out` | 缓入缓出曲线 |
|
|
||||||
| `win_impl::find_window(pid)` | `find_cb` / `FOUND_HWND` | 按 pid 枚举顶层窗口 |
|
|
||||||
| `win_impl::covers_monitor(raw)` | `covers_monitor` | 判断窗口是否铺满显示器 |
|
|
||||||
| `win_impl::get_rect(raw)` | `get_rect` | 取窗口矩形 |
|
|
||||||
| `win_impl::focus(raw)` | `SetForegroundWindow` | 把焦点交给游戏 |
|
|
||||||
| `win_impl::dump_pid_windows(pid)` | `dump_pid_windows` | **调试用**:列出 pid 下所有窗口 |
|
|
||||||
| `#[tauri::command] mc_transition_fade_done` | — | 前端淡出完成回调 |
|
|
||||||
| `#[tauri::command] mc_transition_cover_ready` | — | 遮罩出现完成回调 |
|
|
||||||
| `#[tauri::command] mc_transition_logo_in_done` | — | logo 渐显完成回调 |
|
|
||||||
| `#[tauri::command] mc_transition_logo_out_done` | — | logo 渐隐完成回调 |
|
|
||||||
|
|
||||||
**⚠️ 三个关键设计约束(改代码前必读)**:
|
**方案**:debug 版 logger 增加一层文件输出,写入与 release 相同的
|
||||||
|
`launcher_logs` 目录,文件名带 `session_debug_` 前缀。
|
||||||
|
|
||||||
1. **HWND 不能跨 await 持有**。
|
## 结论与边界
|
||||||
`HWND` 是裸指针,非 `Send`,一旦跨 `await` 持有,async 块就不是 `Send`,无法被 `tauri::async_runtime::spawn`。
|
|
||||||
→ 因此全程用 `usize` 保存窗口句柄(变量 `mc_raw`),只在同步的 win32 调用里通过 `to_hwnd()` 临时转回。
|
|
||||||
搜索关键词:`to_hwnd`、`FOUND_HWND`
|
|
||||||
|
|
||||||
2. **`BOOL` 的导入路径**。
|
- **启动器侧**:准备阶段已从 18.6s 优化到约 0.1s,拉起进程约 0.6s,已到极限。
|
||||||
`windows` crate 0.61 中,`BOOL` 位于 `windows::core::BOOL`,**不在** `Win32::Foundation`。
|
- **游戏侧**:窗口出现前约 11s、完整加载约 45.8s(230 mod 的 NeoForge 整合包),
|
||||||
搜索关键词:`use windows::core::BOOL`
|
属整合包固有成本,任何启动器无法缩短。
|
||||||
|
- 曾尝试开启 NeoForge 早期窗口(`fml.toml` 的 `earlyWindowControl`),
|
||||||
|
该整合包下与 mod 冲突导致更慢(11s → 30s),**已回滚**。
|
||||||
|
|
||||||
3. **窗口尺寸过滤阈值**。
|
## 构建
|
||||||
`find_cb` 里只认宽高都 > 50 的可见窗口,用来排除工具窗口 / 0 尺寸窗口。
|
|
||||||
|
|
||||||
**⚠️ 调试代码仍在(上线前需清理)**:
|
```bash
|
||||||
|
# debug(含计时埋点、文件日志)
|
||||||
- `fn dbg(msg)` —— 把日志直接写入 `%USERPROFILE%\mc_transition_debug.log`,绕过 tracing 配置
|
|
||||||
- 全文件多处 `dbg(...)` 调用
|
|
||||||
- 等待窗口期间每 2 秒调用一次 `dump_pid_windows`
|
|
||||||
- 搜索关键词:`fn dbg(`、`mc_transition_debug.log`、`dump_pid_windows`
|
|
||||||
|
|
||||||
### 2. `apps/app/src/main.rs`
|
|
||||||
|
|
||||||
- 第 25 行附近:`mod mc_transition;`
|
|
||||||
- `invoke_handler` 的 `generate_handler!` 宏内注册 4 个命令:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
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,
|
|
||||||
```
|
|
||||||
|
|
||||||
- 搜索关键词:`mod mc_transition`、`mc_transition_fade_done`
|
|
||||||
|
|
||||||
### 3. `apps/app/src/lightweight_mode.rs`
|
|
||||||
|
|
||||||
`process_event` 中 `None if payload.event == "launched"` 分支(约 250 行起):
|
|
||||||
|
|
||||||
- 新增调用:`crate::mc_transition::run(&app, payload.pid, payload.maximize_window).await;`
|
|
||||||
- 过渡完成后**统一**进入轻量模式:`state.enter(&app)`
|
|
||||||
- **删除**了原来分别判断 `settings.enter_lightweight_mode_on_game_launch` 与 `settings.hide_on_process_start` 的逻辑
|
|
||||||
- 搜索关键词:`crate::mc_transition::run`、`launched`、`enter lightweight mode after transition`
|
|
||||||
|
|
||||||
**执行顺序**:
|
|
||||||
```
|
|
||||||
maximize_minecraft_window(若用户勾选最大化)
|
|
||||||
→ mc_transition::run(完整过渡动画)
|
|
||||||
→ state.enter()(隐藏到托盘)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. `apps/app/Cargo.toml`
|
|
||||||
|
|
||||||
`[target."cfg(windows)".dependencies.windows]` 的 features 中新增:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
"Win32_Graphics_Gdi",
|
|
||||||
```
|
|
||||||
|
|
||||||
> 原因:`GetMonitorInfoW` / `MonitorFromWindow` / `MONITORINFO` 属于 GDI 模块,不加会编译失败。
|
|
||||||
|
|
||||||
- 搜索关键词:`Win32_Graphics_Gdi`
|
|
||||||
|
|
||||||
### 5. `apps/app-frontend/src/App.vue`
|
|
||||||
|
|
||||||
**变量声明**(约 376~381 行):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
let unlistenMcTransitionFadeout: (() => void) | undefined
|
|
||||||
let unlistenMcTransitionFocus: (() => void) | undefined
|
|
||||||
let unlistenMcTransitionCover: (() => void) | undefined
|
|
||||||
let unlistenMcTransitionLogoIn: (() => void) | undefined
|
|
||||||
let unlistenMcTransitionLogoOut: (() => void) | undefined
|
|
||||||
let unlistenMcTransitionReset: (() => void) | undefined
|
|
||||||
```
|
|
||||||
|
|
||||||
**`onMounted` 内注册的监听**(约 500 行起):
|
|
||||||
|
|
||||||
| 监听事件 | 作用 |
|
|
||||||
|---|---|
|
|
||||||
| `mc-transition-fadeout` | `#app` 设 `opacity: 0` + 500ms 过渡,510ms 后回调 `mc_transition_fade_done` |
|
|
||||||
| `mc-transition-cover-show` | 动态创建遮罩 div(主题色背景 + 隐藏的 logo),渐显后回调 `mc_transition_cover_ready` |
|
|
||||||
| `mc-transition-logo-in` | logo 渐显(opacity 0→1),520ms 后回调 `mc_transition_logo_in_done` |
|
|
||||||
| `mc-transition-logo-out` | 遮罩背景 → `#db1f29` + logo 渐隐,520ms 后回调 `mc_transition_logo_out_done` |
|
|
||||||
| `mc-transition-reset-opacity` | 重置 `#app` 透明度 + 移除遮罩 |
|
|
||||||
|
|
||||||
**透明度重置(修复重开透明)**——三重兜底:
|
|
||||||
- `window` 的 `focus` 事件
|
|
||||||
- `document` 的 `visibilitychange` 事件
|
|
||||||
- Tauri 的 `getCurrentWindow().onFocusChanged`
|
|
||||||
|
|
||||||
任一触发即调用 `resetLauncherOpacity()`,把 `#app` 的 opacity 清空。
|
|
||||||
|
|
||||||
**`onUnmounted` 内清理**(约 674 行起):6 个 `unlistenMcTransition*.?.()`
|
|
||||||
|
|
||||||
- 搜索关键词:`unlistenMcTransitionFadeout`、`mc-transition-fadeout`、`mc_transition_fade_done`、`mcCoverEl`、`resetLauncherOpacity`
|
|
||||||
|
|
||||||
**遮罩实现细节**:
|
|
||||||
|
|
||||||
遮罩是**运行时动态创建**的 DOM(不是 Vue template),避免改动庞大的 template 结构:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
mcCoverEl = document.createElement('div') // 固定定位、铺满、主题色背景、最高 z-index
|
|
||||||
mcCoverImg = document.createElement('img') // /mojang-logo.svg,初始 opacity 0
|
|
||||||
```
|
|
||||||
|
|
||||||
- 遮罩 id:`mc-transition-cover`
|
|
||||||
- 背景色:`var(--color-brand, #db1f29)`(跟随启动器主题色)
|
|
||||||
- logo 宽度:42%,最大 640px
|
|
||||||
|
|
||||||
### 6. `apps/app-frontend/public/mojang-logo.svg`(新建)
|
|
||||||
|
|
||||||
过渡遮罩上显示的 Mojang 官方 logo(矢量图)。
|
|
||||||
|
|
||||||
## 四、实测记录
|
|
||||||
|
|
||||||
调试日志 `%USERPROFILE%\mc_transition_debug.log` 中的一次完整流程实录:
|
|
||||||
|
|
||||||
```
|
|
||||||
run() invoked pid=3088 maximize=false
|
|
||||||
try#0 ... try#38 candidates: [] <- MC 窗口约 20 秒后才出现
|
|
||||||
FOUND hwnd=0x3b03da try#40 covers=false
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键发现**:
|
|
||||||
- **MC 窗口需要约 20 秒才出现**(带 mod 的实例更慢),所以等待轮询必须足够长(当前 60s)。
|
|
||||||
- 早期版本只等 5 秒,导致「启动器毫无变化」的假象——实际是超时静默放弃了。
|
|
||||||
|
|
||||||
## 五、编译与运行(Windows)
|
|
||||||
|
|
||||||
### 前置准备
|
|
||||||
|
|
||||||
```bat
|
|
||||||
:: 1. 子模块(本项目不是 git 仓库,需手动 clone)
|
|
||||||
git clone https://github.com/Cubitect/cubiomes.git apps/app/vendor/cubiomes
|
|
||||||
git clone https://github.com/Axolotl-Launcher/blockbench-skin-standalone.git third-party/blockbench
|
|
||||||
|
|
||||||
:: 2. 环境变量(或把 .env.prod 复制为 packages/app-lib/.env)
|
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;%PATH%"
|
|
||||||
set "MODRINTH_URL=https://modrinth.com/"
|
|
||||||
set "MODRINTH_API_BASE_URL=https://api.modrinth.com/"
|
|
||||||
set "MODRINTH_ARCHON_BASE_URL=https://archon.modrinth.com/"
|
|
||||||
set "MODRINTH_API_URL=https://api.modrinth.com/v2/"
|
|
||||||
set "MODRINTH_API_URL_V3=https://api.modrinth.com/v3/"
|
|
||||||
set "MODRINTH_SOCKET_URL=wss://api.modrinth.com/"
|
|
||||||
set "MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 构建
|
|
||||||
|
|
||||||
```bat
|
|
||||||
:: 前端
|
|
||||||
pnpm install
|
|
||||||
pnpm --filter @modrinth/app-frontend run build
|
|
||||||
|
|
||||||
:: 后端 debug(产物 target/debug/theseus_gui.exe,约 155 MB)
|
|
||||||
cargo build -p theseus_gui
|
cargo build -p theseus_gui
|
||||||
|
|
||||||
:: 后端 release(产物 target/release/theseus_gui.exe,约 75 MB)
|
# release
|
||||||
cargo build -p theseus_gui --release
|
cargo build --release -p theseus_gui
|
||||||
```
|
```
|
||||||
|
|
||||||
### 已知坑
|
|
||||||
|
|
||||||
| 坑 | 现象 | 解决 |
|
|
||||||
|---|---|---|
|
|
||||||
| 子模块缺失 | `build.rs` panic:`Blockbench skin editor submodule is missing` | 手动 clone 两个子模块 |
|
|
||||||
| 资源路径校验 | `resource path resources\blockbench-skin doesn't exist` | 构建会自动生成;若报错手动建空目录 |
|
|
||||||
| 环境变量缺失 | `environment variable MODRINTH_API_URL not defined` | 见上方环境变量,或复制 `.env` |
|
|
||||||
| 数据库迁移冲突 | `migration ... was previously applied but is missing` | 删除 `%APPDATA%\cool.starlight.launcher\release\app.db` 后重启 |
|
|
||||||
| exe 被占用 | `failed to remove file ...theseus_gui.exe`(拒绝访问) | `taskkill /F /IM theseus_gui.exe` 后重编 |
|
|
||||||
| `pnpm app:build` 失败 | 找不到 cargo | 那会走完整 `tauri build` 打包;只构建前端请用 `--filter @modrinth/app-frontend` |
|
|
||||||
|
|
||||||
## 六、待办 / TODO
|
|
||||||
|
|
||||||
| # | 事项 | 优先级 | 关键词 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | **清理调试代码**:删 `fn dbg` + 所有 `dbg(...)` + `dump_pid_windows` 调用 | 高 | `fn dbg(`、`mc_transition_debug.log` |
|
|
||||||
| 2 | 等待窗口超时(60s)可做成可配置 / 事件驱动 | 中 | `FIND_RETRIES` |
|
|
||||||
| 3 | 多显示器场景未充分测试(已按窗口所在屏取显示器) | 中 | `monitor_rect` |
|
|
||||||
| 4 | 前端淡出若卡顿靠 700ms 兜底 | 低 | `FADE_TIMEOUT_MS` |
|
|
||||||
| 5 | `SetForegroundWindow` 可能被系统拒绝,未做失败重试 | 低 | `focus` |
|
|
||||||
| 6 | 未来若做 macOS / Linux 支持:需 `CGWindowListCopyWindowInfo` / X11(Wayland 基本不可行) | 低 | `#[cfg(target_os]` |
|
|
||||||
|
|
||||||
## 七、快速搜索索引
|
|
||||||
|
|
||||||
| 想找什么 | 搜这个关键词 |
|
|
||||||
|---|---|
|
|
||||||
| 核心模块 | `mc_transition` |
|
|
||||||
| 过渡主流程 | `pub async fn run(app` |
|
|
||||||
| 八步流程标记 | `step 1` ~ `step 8` |
|
|
||||||
| 找 MC 窗口 | `find_window`、`find_cb` |
|
|
||||||
| 全屏判定 | `covers_monitor` |
|
|
||||||
| 缩放动画 | `animate_resize`、`ANIM_DURATION_MS` |
|
|
||||||
| 缓动曲线 | `ease_in_out` |
|
|
||||||
| 纯色遮罩事件 | `mc-transition-cover-show` |
|
|
||||||
| logo 渐显事件 | `mc-transition-logo-in` |
|
|
||||||
| logo 渐隐/变红事件 | `mc-transition-logo-out` |
|
|
||||||
| 淡出事件 | `mc-transition-fadeout` |
|
|
||||||
| 透明度重置事件 | `mc-transition-reset-opacity` |
|
|
||||||
| 前端回调命令 | `mc_transition_cover_ready`、`mc_transition_logo_in_done`、`mc_transition_logo_out_done`、`mc_transition_fade_done` |
|
|
||||||
| 接入点 | `crate::mc_transition::run` |
|
|
||||||
| 透明度重置函数 | `resetLauncherOpacity` |
|
|
||||||
| 遮罩 DOM | `mc-transition-cover`、`mcCoverEl` |
|
|
||||||
| 调试日志(待删) | `fn dbg(`、`mc_transition_debug.log` |
|
|
||||||
| 调试日志文件 | `%USERPROFILE%\mc_transition_debug.log` |
|
|
||||||
| windows feature | `Win32_Graphics_Gdi` |
|
|
||||||
| 数据库冲突 | `previously applied but is missing` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_文档结束。功能已跑通,上线前记得清理调试代码喵。_
|
|
||||||
@ -249,10 +249,16 @@ impl LightweightMode {
|
|||||||
}
|
}
|
||||||
None if payload.event == "launched" => {
|
None if payload.event == "launched" => {
|
||||||
let app = app.clone();
|
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 {
|
tauri::async_runtime::spawn(async move {
|
||||||
if payload.maximize_window {
|
|
||||||
maximize_minecraft_window(payload.pid).await;
|
|
||||||
}
|
|
||||||
// 窗口过渡:把启动器全屏遮罩 → 缩放 → 淡出 → 最小化
|
// 窗口过渡:把启动器全屏遮罩 → 缩放 → 淡出 → 最小化
|
||||||
crate::mc_transition::run(
|
crate::mc_transition::run(
|
||||||
&app,
|
&app,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
//! Minecraft 启动时的窗口过渡动画(仅 Windows 生效)。
|
//! Minecraft 启动时的窗口过渡动画(仅 Windows 生效)。
|
||||||
//!
|
//!
|
||||||
//! 流程(四阶段):
|
//! 流程(四阶段):
|
||||||
//! 阶段A:启动器放大到全屏(1s 动画),全程置顶 + 聚焦;
|
//! 阶段A:启动器放大到全屏(0.2s 动画),全程置顶 + 聚焦;
|
||||||
//! 阶段B:等待 MC 窗口出现(最多 60s),期间启动器盖住游戏;
|
//! 阶段B:等待 MC 窗口出现(最多 60s),期间启动器盖住游戏;
|
||||||
//! 阶段C:获取 MC 窗口位置/大小,1s 动画缩放到相同大小;
|
//! 阶段C:获取 MC 窗口位置/大小,1s 动画缩放到相同大小;
|
||||||
//! 阶段D:大小一致后,0.5s 淡出(不与缩放同步);
|
//! 阶段D:大小一致后,0.5s 淡出(不与缩放同步);
|
||||||
@ -22,9 +22,9 @@ pub const MAIN_WINDOW_LABEL: &str = "main";
|
|||||||
/// 等待 MC 窗口出现的轮询次数与间隔(120 × 500ms = 60s)。
|
/// 等待 MC 窗口出现的轮询次数与间隔(120 × 500ms = 60s)。
|
||||||
const FIND_RETRIES: u32 = 120;
|
const FIND_RETRIES: u32 = 120;
|
||||||
const FIND_INTERVAL_MS: u64 = 500;
|
const FIND_INTERVAL_MS: u64 = 500;
|
||||||
/// 缩放动画总时长与帧数(1000ms / 50 帧 = 20ms 一帧)。
|
/// 缩放动画总时长与帧数(200ms / 25 帧 = 8ms 一帧)。
|
||||||
const ANIM_DURATION_MS: u64 = 400;
|
const ANIM_DURATION_MS: u64 = 200;
|
||||||
const ANIM_STEPS: u32 = 50;
|
const ANIM_STEPS: u32 = 25;
|
||||||
/// 等待前端完成 CSS 淡出的超时兜底(毫秒)。淡出 500ms,留足余量。
|
/// 等待前端完成 CSS 淡出的超时兜底(毫秒)。淡出 500ms,留足余量。
|
||||||
const FADE_TIMEOUT_MS: u64 = 700;
|
const FADE_TIMEOUT_MS: u64 = 700;
|
||||||
|
|
||||||
@ -48,26 +48,11 @@ static LOGO_OUT_DONE: std::sync::Mutex<
|
|||||||
Option<tokio::sync::oneshot::Sender<()>>,
|
Option<tokio::sync::oneshot::Sender<()>>,
|
||||||
> = std::sync::Mutex::new(None);
|
> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
/// 调试日志:直接追加到用户目录下固定文件,绕过 tracing 配置。
|
|
||||||
/// TODO(上线前清理):连同所有 `dbg(...)` 调用一起删除。
|
|
||||||
fn dbg(msg: &str) {
|
|
||||||
use std::io::Write;
|
|
||||||
let base =
|
|
||||||
std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\".to_string());
|
|
||||||
let path = format!("{base}\\mc_transition_debug.log");
|
|
||||||
if let Ok(mut f) =
|
|
||||||
std::fs::OpenOptions::new().create(true).append(true).open(path)
|
|
||||||
{
|
|
||||||
let _ = writeln!(f, "{msg}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 前端完成淡出动画后的回调命令。
|
/// 前端完成淡出动画后的回调命令。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn mc_transition_fade_done() {
|
pub fn mc_transition_fade_done() {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
dbg("fade_done callback");
|
|
||||||
if let Ok(mut guard) = FADE_DONE.lock()
|
if let Ok(mut guard) = FADE_DONE.lock()
|
||||||
&& let Some(tx) = guard.take()
|
&& let Some(tx) = guard.take()
|
||||||
{
|
{
|
||||||
@ -81,7 +66,6 @@ pub fn mc_transition_fade_done() {
|
|||||||
pub fn mc_transition_cover_ready() {
|
pub fn mc_transition_cover_ready() {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
dbg("cover_ready callback");
|
|
||||||
if let Ok(mut guard) = COVER_DONE.lock()
|
if let Ok(mut guard) = COVER_DONE.lock()
|
||||||
&& let Some(tx) = guard.take()
|
&& let Some(tx) = guard.take()
|
||||||
{
|
{
|
||||||
@ -95,7 +79,6 @@ pub fn mc_transition_cover_ready() {
|
|||||||
pub fn mc_transition_logo_in_done() {
|
pub fn mc_transition_logo_in_done() {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
dbg("logo_in_done callback");
|
|
||||||
if let Ok(mut guard) = LOGO_IN_DONE.lock()
|
if let Ok(mut guard) = LOGO_IN_DONE.lock()
|
||||||
&& let Some(tx) = guard.take()
|
&& let Some(tx) = guard.take()
|
||||||
{
|
{
|
||||||
@ -109,7 +92,6 @@ pub fn mc_transition_logo_in_done() {
|
|||||||
pub fn mc_transition_logo_out_done() {
|
pub fn mc_transition_logo_out_done() {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
dbg("logo_out_done callback");
|
|
||||||
if let Ok(mut guard) = LOGO_OUT_DONE.lock()
|
if let Ok(mut guard) = LOGO_OUT_DONE.lock()
|
||||||
&& let Some(tx) = guard.take()
|
&& let Some(tx) = guard.take()
|
||||||
{
|
{
|
||||||
@ -132,13 +114,12 @@ fn ease_in_out(t: f64) -> f64 {
|
|||||||
/// 若起止相同则直接返回;结束时补一帧精确坐标,避免累积误差。
|
/// 若起止相同则直接返回;结束时补一帧精确坐标,避免累积误差。
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
async fn animate_resize(
|
async fn animate_resize(
|
||||||
window: &WebviewWindow,
|
_window: &WebviewWindow,
|
||||||
|
hwnd: usize,
|
||||||
from: (i32, i32, u32, u32),
|
from: (i32, i32, u32, u32),
|
||||||
to: (i32, i32, u32, u32),
|
to: (i32, i32, u32, u32),
|
||||||
duration_ms: u64,
|
duration_ms: u64,
|
||||||
) {
|
) {
|
||||||
use tauri::{PhysicalPosition, PhysicalSize};
|
|
||||||
|
|
||||||
if from == to {
|
if from == to {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -150,37 +131,70 @@ async fn animate_resize(
|
|||||||
let y = from.1 as f64 + (to.1 - from.1) 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 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;
|
let h = from.3 as f64 + (to.3 as i64 - from.3 as i64) as f64 * e;
|
||||||
let _ =
|
// 单次 SetWindowPos(移动+缩放一把过)比重绘两次的
|
||||||
window.set_position(PhysicalPosition::new(x as i32, y as i32));
|
// set_position/set_size 更顺滑,也避免无边框窗口的
|
||||||
let _ = window.set_size(PhysicalSize::new(
|
// 不可见 resize border 造成的偏移。
|
||||||
w.max(1.0) as u32,
|
win_impl::set_client_rect(
|
||||||
h.max(1.0) as u32,
|
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;
|
tokio::time::sleep(std::time::Duration::from_millis(step_ms)).await;
|
||||||
}
|
}
|
||||||
// 补最后一帧精确值
|
// 补最后一帧精确值(同样只设可见客户区,避免无边框边框造成偏移)
|
||||||
let _ = window.set_position(PhysicalPosition::new(to.0, to.1));
|
win_impl::set_client_rect(
|
||||||
let _ = window.set_size(PhysicalSize::new(to.2.max(1), to.3.max(1)));
|
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) {
|
pub async fn run(app: &AppHandle, pid: u32, _maximize: bool) {
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
dbg("run() aborted: pid=0");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||||
dbg("run() aborted: main window not found");
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
dbg(&format!("run() invoked pid={pid}"));
|
|
||||||
|
|
||||||
// 记录启动器当前矩形作为起点
|
#[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 = {
|
let start = {
|
||||||
let p = window.outer_position().map(|p| (p.x, p.y)).unwrap_or((0, 0));
|
#[cfg(target_os = "windows")]
|
||||||
let s =
|
{
|
||||||
window.outer_size().map(|s| (s.width, s.height)).unwrap_or((1280, 800));
|
win_impl::get_client_rect(hwnd_raw).unwrap_or_else(|| {
|
||||||
(p.0, p.1, s.0, s.1)
|
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)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
dbg(&format!("launcher start rect = {start:?}"));
|
|
||||||
|
|
||||||
// 全屏目标 = 启动器当前所在显示器的完整矩形
|
// 全屏目标 = 启动器当前所在显示器的完整矩形
|
||||||
let fullscreen = match window
|
let fullscreen = match window
|
||||||
@ -193,88 +207,74 @@ pub async fn run(app: &AppHandle, pid: u32, _maximize: bool) {
|
|||||||
(m.position().x, m.position().y, m.size().width, m.size().height)
|
(m.position().x, m.position().y, m.size().width, m.size().height)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
dbg("no monitor info, abort");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
dbg(&format!("fullscreen target = {fullscreen:?}"));
|
|
||||||
|
|
||||||
|
// 窗口可能处于最小化/隐藏(轻量模式遗留),先恢复可见再动画,
|
||||||
|
// 否则遮罩虽然在 DOM 里建好,用户却看不到。
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
// 全程置顶 + 聚焦
|
// 全程置顶 + 聚焦
|
||||||
let _ = window.set_always_on_top(true);
|
let _ = window.set_always_on_top(true);
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
|
|
||||||
// ---------- 第①步:显示纯色遮罩(无图,主题色) ----------
|
// ---------- 第①步:显示纯色遮罩(无图,主题色) ----------
|
||||||
dbg("step 1: show plain cover");
|
|
||||||
let (cover_tx, cover_rx) = tokio::sync::oneshot::channel::<()>();
|
let (cover_tx, cover_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
if let Ok(mut guard) = COVER_DONE.lock() {
|
if let Ok(mut guard) = COVER_DONE.lock() {
|
||||||
*guard = Some(cover_tx);
|
*guard = Some(cover_tx);
|
||||||
}
|
}
|
||||||
let _ = app.emit("mc-transition-cover-show", ());
|
let _ = app.emit("mc-transition-cover-show", ());
|
||||||
let covered = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
std::time::Duration::from_millis(1000),
|
std::time::Duration::from_millis(1000),
|
||||||
cover_rx,
|
cover_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
dbg(&format!("step 1: cover ready ok={}", covered.is_ok()));
|
|
||||||
|
|
||||||
// ---------- 第②步:放大到全屏(1s) ----------
|
// ---------- 第②步:放大到全屏(0.2s) ----------
|
||||||
dbg("step 2: expand to fullscreen");
|
animate_resize(&window, hwnd_raw, start, fullscreen, ANIM_DURATION_MS)
|
||||||
animate_resize(&window, start, fullscreen, ANIM_DURATION_MS).await;
|
.await;
|
||||||
dbg("step 2 done");
|
|
||||||
|
|
||||||
// ---------- 第③步:全屏后,渐显 SVG logo ----------
|
// ---------- 第③步:全屏后,渐显 SVG logo ----------
|
||||||
dbg("step 3: fade in logo");
|
|
||||||
let (lin_tx, lin_rx) = tokio::sync::oneshot::channel::<()>();
|
let (lin_tx, lin_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
if let Ok(mut guard) = LOGO_IN_DONE.lock() {
|
if let Ok(mut guard) = LOGO_IN_DONE.lock() {
|
||||||
*guard = Some(lin_tx);
|
*guard = Some(lin_tx);
|
||||||
}
|
}
|
||||||
let _ = app.emit("mc-transition-logo-in", ());
|
let _ = app.emit("mc-transition-logo-in", ());
|
||||||
let logo_in = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
std::time::Duration::from_millis(1500),
|
std::time::Duration::from_millis(1500),
|
||||||
lin_rx,
|
lin_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
dbg(&format!("step 3: logo in done ok={}", logo_in.is_ok()));
|
|
||||||
|
|
||||||
// ---------- 等待 MC 窗口出现(最多 60s) ----------
|
// ---------- 等待 MC 窗口出现(最多 60s) ----------
|
||||||
dbg("waiting for MC window");
|
|
||||||
let mut mc_raw: usize = 0;
|
let mut mc_raw: usize = 0;
|
||||||
for i in 0..FIND_RETRIES {
|
for _ in 0..FIND_RETRIES {
|
||||||
if let Some(raw) = win_impl::find_window(pid) {
|
if let Some(raw) = win_impl::find_window(pid) {
|
||||||
mc_raw = raw;
|
mc_raw = raw;
|
||||||
dbg(&format!("MC window found at try#{i} hwnd={raw:#x}"));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if i % 4 == 0 {
|
|
||||||
dbg(&format!(
|
|
||||||
"try#{i} pid={pid} candidates: {:?}",
|
|
||||||
win_impl::dump_pid_windows(pid)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(FIND_INTERVAL_MS))
|
tokio::time::sleep(std::time::Duration::from_millis(FIND_INTERVAL_MS))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
if mc_raw == 0 {
|
if mc_raw == 0 {
|
||||||
dbg("MC window NOT FOUND -> restore launcher");
|
|
||||||
let _ = window.set_always_on_top(false);
|
let _ = window.set_always_on_top(false);
|
||||||
animate_resize(&window, fullscreen, start, 300).await;
|
animate_resize(&window, hwnd_raw, fullscreen, start, 300).await;
|
||||||
let _ = app.emit("mc-transition-reset-opacity", ());
|
let _ = app.emit("mc-transition-reset-opacity", ());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 第④步:缩小前,背景变 Mojang 红 + 渐隐 logo ----------
|
// ---------- 第④步:缩小前,背景变 Mojang 红 + 渐隐 logo ----------
|
||||||
dbg("step 4: turn red + fade out logo");
|
|
||||||
let (lout_tx, lout_rx) = tokio::sync::oneshot::channel::<()>();
|
let (lout_tx, lout_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
if let Ok(mut guard) = LOGO_OUT_DONE.lock() {
|
if let Ok(mut guard) = LOGO_OUT_DONE.lock() {
|
||||||
*guard = Some(lout_tx);
|
*guard = Some(lout_tx);
|
||||||
}
|
}
|
||||||
let _ = app.emit("mc-transition-logo-out", ());
|
let _ = app.emit("mc-transition-logo-out", ());
|
||||||
let logo_out = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
std::time::Duration::from_millis(1500),
|
std::time::Duration::from_millis(1500),
|
||||||
lout_rx,
|
lout_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
dbg(&format!("step 4: logo out done ok={}", logo_out.is_ok()));
|
|
||||||
|
|
||||||
// ---------- 第⑤步:缩小到 MC 窗口矩形(1s) ----------
|
// ---------- 第⑤步:缩小到 MC 窗口矩形(1s) ----------
|
||||||
let mc_rect = win_impl::get_rect(mc_raw).unwrap_or((
|
let mc_rect = win_impl::get_rect(mc_raw).unwrap_or((
|
||||||
@ -283,8 +283,6 @@ pub async fn run(app: &AppHandle, pid: u32, _maximize: bool) {
|
|||||||
fullscreen.2 as i32,
|
fullscreen.2 as i32,
|
||||||
fullscreen.3 as i32,
|
fullscreen.3 as i32,
|
||||||
));
|
));
|
||||||
let covers = win_impl::covers_monitor(mc_raw);
|
|
||||||
dbg(&format!("step 5: MC rect={mc_rect:?} covers_monitor={covers}"));
|
|
||||||
let target = (
|
let target = (
|
||||||
mc_rect.0,
|
mc_rect.0,
|
||||||
mc_rect.1,
|
mc_rect.1,
|
||||||
@ -293,36 +291,30 @@ pub async fn run(app: &AppHandle, pid: u32, _maximize: bool) {
|
|||||||
);
|
);
|
||||||
let _ = window.set_always_on_top(true);
|
let _ = window.set_always_on_top(true);
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
animate_resize(&window, fullscreen, target, ANIM_DURATION_MS).await;
|
animate_resize(&window, hwnd_raw, fullscreen, target, ANIM_DURATION_MS)
|
||||||
dbg("step 5 done");
|
.await;
|
||||||
|
|
||||||
// ---------- 第⑥步:等待 1s(游戏稳定,遮罩仍在) ----------
|
// ---------- 第⑥步:等待 1s(游戏稳定,遮罩仍在) ----------
|
||||||
dbg("step 6: settle 1s");
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||||
dbg("step 6 done");
|
|
||||||
|
|
||||||
// ---------- 第⑦步:淡出(0.5s,含遮罩一起) ----------
|
// ---------- 第⑦步:淡出(0.5s,含遮罩一起) ----------
|
||||||
dbg("step 7: fade out, waiting frontend");
|
|
||||||
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
|
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
if let Ok(mut guard) = FADE_DONE.lock() {
|
if let Ok(mut guard) = FADE_DONE.lock() {
|
||||||
*guard = Some(done_tx);
|
*guard = Some(done_tx);
|
||||||
}
|
}
|
||||||
let _ = app.emit("mc-transition-fadeout", ());
|
let _ = app.emit("mc-transition-fadeout", ());
|
||||||
let faded = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
std::time::Duration::from_millis(FADE_TIMEOUT_MS),
|
std::time::Duration::from_millis(FADE_TIMEOUT_MS),
|
||||||
done_rx,
|
done_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
dbg(&format!("step 7: fade wait ok={}", faded.is_ok()));
|
|
||||||
|
|
||||||
// ---------- 第⑧步:聚焦游戏 + 最小化启动器 ----------
|
// ---------- 第⑧步:聚焦游戏 + 最小化启动器 ----------
|
||||||
dbg("step 8: focus game + minimize launcher");
|
|
||||||
let _ = window.set_always_on_top(false);
|
let _ = window.set_always_on_top(false);
|
||||||
win_impl::focus(mc_raw);
|
win_impl::focus(mc_raw);
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
|
||||||
let _ = window.minimize();
|
let _ = window.minimize();
|
||||||
let _ = app.emit("mc-transition-reset-opacity", ());
|
let _ = app.emit("mc-transition-reset-opacity", ());
|
||||||
dbg("step 8 done (launcher minimized)");
|
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
pub async fn run(_app: &AppHandle, _pid: u32, _maximize: bool) {}
|
pub async fn run(_app: &AppHandle, _pid: u32, _maximize: bool) {}
|
||||||
@ -333,13 +325,11 @@ mod win_impl {
|
|||||||
Mutex,
|
Mutex,
|
||||||
atomic::{AtomicU32, AtomicUsize, Ordering},
|
atomic::{AtomicU32, AtomicUsize, Ordering},
|
||||||
};
|
};
|
||||||
use windows::Win32::Foundation::{HWND, LPARAM, RECT};
|
use windows::Win32::Foundation::{HWND, LPARAM, POINT, RECT};
|
||||||
use windows::Win32::Graphics::Gdi::{
|
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||||
GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow,
|
|
||||||
};
|
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
EnumWindows, GetWindowRect, GetWindowThreadProcessId, IsWindowVisible,
|
EnumWindows, GetClientRect, GetWindowRect, GetWindowThreadProcessId,
|
||||||
SetForegroundWindow,
|
IsWindowVisible, SetForegroundWindow,
|
||||||
};
|
};
|
||||||
use windows::core::BOOL;
|
use windows::core::BOOL;
|
||||||
|
|
||||||
@ -376,45 +366,6 @@ mod win_impl {
|
|||||||
BOOL(1)
|
BOOL(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 诊断用:列出属于 `pid` 的所有顶层窗口(含不可见/小窗口)。
|
|
||||||
/// 返回 (hwnd, 宽, 高, 是否可见)。
|
|
||||||
pub fn dump_pid_windows(pid: u32) -> Vec<(usize, i32, i32, bool)> {
|
|
||||||
use windows::core::BOOL as B;
|
|
||||||
|
|
||||||
static DUMP_PID: AtomicU32 = AtomicU32::new(0);
|
|
||||||
static DUMP: Mutex<Vec<(usize, i32, i32, bool)>> =
|
|
||||||
Mutex::new(Vec::new());
|
|
||||||
|
|
||||||
unsafe extern "system" fn cb(hwnd: HWND, _: LPARAM) -> B {
|
|
||||||
let mut p = 0u32;
|
|
||||||
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut p)) };
|
|
||||||
if p != DUMP_PID.load(Ordering::Relaxed) {
|
|
||||||
return B(1);
|
|
||||||
}
|
|
||||||
let mut r = RECT::default();
|
|
||||||
let _ = unsafe { GetWindowRect(hwnd, &mut r) };
|
|
||||||
let vis = unsafe { IsWindowVisible(hwnd).as_bool() };
|
|
||||||
if let Ok(mut v) = DUMP.lock() {
|
|
||||||
v.push((hwnd.0 as usize, r.right - r.left, r.bottom - r.top, vis));
|
|
||||||
}
|
|
||||||
B(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
let _g = ENUM_LOCK.lock();
|
|
||||||
let _g = match _g {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(_) => return Vec::new(),
|
|
||||||
};
|
|
||||||
DUMP_PID.store(pid, Ordering::Relaxed);
|
|
||||||
if let Ok(mut v) = DUMP.lock() {
|
|
||||||
v.clear();
|
|
||||||
}
|
|
||||||
unsafe {
|
|
||||||
let _ = EnumWindows(Some(cb), LPARAM(0));
|
|
||||||
}
|
|
||||||
DUMP.lock().map(|v| v.clone()).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查找属于 `pid` 的可见顶层窗口,返回句柄原始值(0 表示未找到)。
|
/// 查找属于 `pid` 的可见顶层窗口,返回句柄原始值(0 表示未找到)。
|
||||||
pub fn find_window(pid: u32) -> Option<usize> {
|
pub fn find_window(pid: u32) -> Option<usize> {
|
||||||
let _guard = ENUM_LOCK.lock().ok()?;
|
let _guard = ENUM_LOCK.lock().ok()?;
|
||||||
@ -434,31 +385,63 @@ mod win_impl {
|
|||||||
Some((r.left, r.top, r.right - r.left, r.bottom - r.top))
|
Some((r.left, r.top, r.right - r.left, r.bottom - r.top))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取窗口所在显示器的完整矩形,返回 (x, y, width, height)。
|
/// 取窗口「可见客户区」在屏幕坐标下的矩形,返回 (x, y, w, h)。
|
||||||
pub fn monitor_rect(raw: usize) -> Option<(i32, i32, i32, i32)> {
|
/// 无边框窗口仍带不可见的 resize border,客户区比窗口矩形内缩,
|
||||||
let mon =
|
/// 这正是全屏后左侧留缝、整体偏右的根源。
|
||||||
unsafe { MonitorFromWindow(to_hwnd(raw), MONITOR_DEFAULTTONEAREST) };
|
pub fn get_client_rect(raw: usize) -> Option<(i32, i32, u32, u32)> {
|
||||||
let mut mi = MONITORINFO {
|
if raw == 0 {
|
||||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
|
||||||
rcMonitor: RECT::default(),
|
|
||||||
rcWork: RECT::default(),
|
|
||||||
dwFlags: 0,
|
|
||||||
};
|
|
||||||
if !unsafe { GetMonitorInfoW(mon, &mut mi) }.as_bool() {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let r = mi.rcMonitor;
|
let hwnd = to_hwnd(raw);
|
||||||
Some((r.left, r.top, r.right - r.left, r.bottom - r.top))
|
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 covers_monitor(raw: usize) -> bool {
|
/// 窗口的不可见边框。动画与对齐统一用它,避免左/上缝隙。
|
||||||
let (Some((_, _, w, h)), Some((_, _, mw, mh))) =
|
pub fn set_client_rect(raw: usize, x: i32, y: i32, w: i32, h: i32) {
|
||||||
(get_rect(raw), monitor_rect(raw))
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
else {
|
SWP_NOACTIVATE, SWP_NOZORDER, SetWindowPos,
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
w >= mw && h >= mh
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把前台焦点交给游戏窗口。
|
/// 把前台焦点交给游戏窗口。
|
||||||
|
|||||||
@ -75,8 +75,10 @@ async fn run_with_extra_launch_args_inner(
|
|||||||
extra_launch_args: Option<Vec<String>>,
|
extra_launch_args: Option<Vec<String>>,
|
||||||
gc_intent: Option<GcLaunchIntent>,
|
gc_intent: Option<GcLaunchIntent>,
|
||||||
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
|
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
|
||||||
|
let __t_hosted = std::time::Instant::now();
|
||||||
let _hosted_guard =
|
let _hosted_guard =
|
||||||
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
|
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 state = State::get().await?;
|
||||||
let launch_preparation_timeout =
|
let launch_preparation_timeout =
|
||||||
crate::state::instances::commands::get_instance_launch_context(
|
crate::state::instances::commands::get_instance_launch_context(
|
||||||
|
|||||||
@ -293,30 +293,130 @@ 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.
|
/// Whether the file exists and satisfies the metadata available for it.
|
||||||
/// SHA1 is authoritative when declared; otherwise a declared size still
|
/// SHA1 is authoritative when declared; otherwise a declared size still
|
||||||
/// protects against accepting a partial or truncated file. An unreadable file
|
/// protects against accepting a partial or truncated file. An unreadable file
|
||||||
/// counts as not current so it gets replaced.
|
/// counts as not current so it gets replaced. A previously recorded
|
||||||
|
/// (size, mtime) stamp short-circuits the SHA1 read for unchanged files.
|
||||||
async fn file_is_current(
|
async fn file_is_current(
|
||||||
path: &std::path::Path,
|
path: &std::path::Path,
|
||||||
expected_sha1: Option<&str>,
|
expected_sha1: Option<&str>,
|
||||||
expected_size: Option<u64>,
|
expected_size: Option<u64>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if !path.is_file() {
|
let metadata = match std::fs::metadata(path) {
|
||||||
return false;
|
Ok(metadata) if metadata.is_file() => metadata,
|
||||||
}
|
_ => return false,
|
||||||
|
};
|
||||||
if let Some(expected_size) = expected_size
|
if let Some(expected_size) = expected_size
|
||||||
&& std::fs::metadata(path)
|
&& metadata.len() != expected_size
|
||||||
.map_or(true, |metadata| metadata.len() != expected_size)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
match expected_sha1 {
|
let Some(expected) = expected_sha1 else {
|
||||||
Some(expected) => match fetch::sha1_file_async(path).await {
|
return true;
|
||||||
Ok((_, actual)) => actual.eq_ignore_ascii_case(expected),
|
};
|
||||||
Err(_) => false,
|
|
||||||
},
|
let mtime = file_mtime_nanos(&metadata);
|
||||||
None => true,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -475,18 +575,48 @@ pub(crate) async fn ensure_linked_assets_from(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let objects_dir = direct.assets_dir().join("objects");
|
let objects_dir = direct.assets_dir().join("objects");
|
||||||
let mut missing = Vec::new();
|
// Assets number in the thousands on modern versions; hashing each object
|
||||||
for asset in index.objects.values() {
|
// serially accounted for the bulk of launch latency. Fan the checks out
|
||||||
let hash = &asset.hash;
|
// across a bounded pool (same width the downloader uses).
|
||||||
if hash.len() < 2 {
|
let __t_asset_scan = std::time::Instant::now();
|
||||||
continue;
|
let asset_limit = download_util::task_concurrency_limit(st)
|
||||||
}
|
.map(|limit| limit.saturating_mul(2))
|
||||||
let destination = objects_dir.join(&hash[..2]).join(hash);
|
.unwrap_or(FALLBACK_CONCURRENCY);
|
||||||
let size = u64::from(asset.size);
|
let missing_slot = std::sync::Arc::new(std::sync::Mutex::new(
|
||||||
if !file_is_current(&destination, Some(hash), Some(size)).await {
|
Vec::<(String, u64, PathBuf)>::new(),
|
||||||
missing.push((hash.clone(), size, destination));
|
));
|
||||||
}
|
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());
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
count = missing.len(),
|
count = missing.len(),
|
||||||
@ -620,6 +750,7 @@ pub(crate) async fn ensure_direct_launch_dependencies(
|
|||||||
java_arch: &str,
|
java_arch: &str,
|
||||||
minecraft_updated: bool,
|
minecraft_updated: bool,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
|
load_verify_stamps(st).await;
|
||||||
let mut plans = Vec::new();
|
let mut plans = Vec::new();
|
||||||
if let Some(plan) = linked_client_plan(direct, version_info) {
|
if let Some(plan) = linked_client_plan(direct, version_info) {
|
||||||
plans.push(plan);
|
plans.push(plan);
|
||||||
@ -648,14 +779,41 @@ pub(crate) async fn ensure_direct_launch_dependencies(
|
|||||||
|
|
||||||
// Only fetch what is actually missing so a healthy installation performs
|
// Only fetch what is actually missing so a healthy installation performs
|
||||||
// zero network requests.
|
// zero network requests.
|
||||||
let mut pending = Vec::new();
|
let __t_libscan = std::time::Instant::now();
|
||||||
for plan in plans {
|
let plans_len_dbg = plans.len();
|
||||||
if !file_is_current(&plan.destination, plan.sha1.as_deref(), plan.size)
|
// Verify every planned file concurrently. Hashing a few hundred jars
|
||||||
.await
|
// serially dominated cold-start time; the SHA1 reads are independent so
|
||||||
{
|
// they fan out across the same bounded pool the downloader uses.
|
||||||
pending.push(plan);
|
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());
|
||||||
if !pending.is_empty() {
|
if !pending.is_empty() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
count = pending.len(),
|
count = pending.len(),
|
||||||
@ -672,6 +830,7 @@ pub(crate) async fn ensure_direct_launch_dependencies(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let __t_assets = std::time::Instant::now();
|
||||||
ensure_linked_assets(
|
ensure_linked_assets(
|
||||||
st,
|
st,
|
||||||
direct,
|
direct,
|
||||||
@ -679,7 +838,11 @@ pub(crate) async fn ensure_direct_launch_dependencies(
|
|||||||
version_info.assets == "legacy",
|
version_info.assets == "legacy",
|
||||||
)
|
)
|
||||||
.await?;
|
.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?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1525,8 +1525,11 @@ pub async fn launch_minecraft(
|
|||||||
) -> crate::Result<ProcessMetadata> {
|
) -> crate::Result<ProcessMetadata> {
|
||||||
let instance = &context.instance;
|
let instance = &context.instance;
|
||||||
let content_set = &context.applied_content_set;
|
let content_set = &context.applied_content_set;
|
||||||
|
let mut __lt = std::time::Instant::now();
|
||||||
let mut combined_java_args =
|
let mut combined_java_args =
|
||||||
crate::pack::hosted::java_arguments(&instance.id).await?;
|
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);
|
combined_java_args.extend_from_slice(java_args);
|
||||||
let java_args = combined_java_args.as_slice();
|
let java_args = combined_java_args.as_slice();
|
||||||
|
|
||||||
@ -1832,6 +1835,8 @@ pub async fn launch_minecraft(
|
|||||||
.await?;
|
.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) =
|
let java_version = if let Some(java) =
|
||||||
context.launch_overrides.java_path.as_ref()
|
context.launch_overrides.java_path.as_ref()
|
||||||
@ -1886,6 +1891,8 @@ pub async fn launch_minecraft(
|
|||||||
let java_version =
|
let java_version =
|
||||||
crate::api::jre::check_jre(java_version.path.clone().into()).await?;
|
crate::api::jre::check_jre(java_version.path.clone().into()).await?;
|
||||||
validate_loader_java_version(&version_info, &java_version)?;
|
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
|
// Runtime-verify and fall back for GC arguments against the *actual* JVM
|
||||||
// that will run Minecraft. The frontend supplies an ordered candidate
|
// that will run Minecraft. The frontend supplies an ordered candidate
|
||||||
@ -1915,6 +1922,8 @@ pub async fn launch_minecraft(
|
|||||||
}
|
}
|
||||||
*gc_report = Some(report);
|
*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
|
// Apply the mappable linked-launcher private settings for directly
|
||||||
// associated instances on top of the resolved launch configuration.
|
// associated instances on top of the resolved launch configuration.
|
||||||
@ -2020,6 +2029,8 @@ pub async fn launch_minecraft(
|
|||||||
None => vanilla_client_path,
|
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 args = version_info.arguments.clone().unwrap_or_default();
|
||||||
let mut command = match wrapper {
|
let mut command = match wrapper {
|
||||||
@ -2065,6 +2076,7 @@ pub async fn launch_minecraft(
|
|||||||
&& let (Some(direct), Some(libraries)) =
|
&& let (Some(direct), Some(libraries)) =
|
||||||
(&direct_launch, linked_libraries.as_deref())
|
(&direct_launch, linked_libraries.as_deref())
|
||||||
{
|
{
|
||||||
|
let __t_ensure = std::time::Instant::now();
|
||||||
direct_ensure::ensure_direct_launch_dependencies(
|
direct_ensure::ensure_direct_launch_dependencies(
|
||||||
&state,
|
&state,
|
||||||
direct,
|
direct,
|
||||||
@ -2074,6 +2086,7 @@ pub async fn launch_minecraft(
|
|||||||
minecraft_updated,
|
minecraft_updated,
|
||||||
)
|
)
|
||||||
.await?;
|
.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 {
|
let natives_dir = if let Some(direct) = &direct_launch {
|
||||||
@ -2085,9 +2098,11 @@ pub async fn launch_minecraft(
|
|||||||
};
|
};
|
||||||
// Linked native archives can change outside Axolotl, so rebuild only the
|
// Linked native archives can change outside Axolotl, so rebuild only the
|
||||||
// Axolotl-owned linked cache on every launch. Never mutate linked folders.
|
// 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() {
|
if direct_launch.is_some() && natives_dir.exists() {
|
||||||
io::remove_dir_all(&natives_dir).await?;
|
io::remove_dir_all(&natives_dir).await?;
|
||||||
}
|
}
|
||||||
|
tracing::info!("[launch-timing] remove_old_natives: {}ms", __t_rm.elapsed().as_millis());
|
||||||
if !natives_dir.exists() {
|
if !natives_dir.exists() {
|
||||||
io::create_dir_all(&natives_dir).await?;
|
io::create_dir_all(&natives_dir).await?;
|
||||||
}
|
}
|
||||||
@ -2098,6 +2113,7 @@ pub async fn launch_minecraft(
|
|||||||
// launch; the managed restore path below must never touch them.
|
// launch; the managed restore path below must never touch them.
|
||||||
let target = natives_dir.clone();
|
let target = natives_dir.clone();
|
||||||
let java_arch = java_version.architecture.clone();
|
let java_arch = java_version.architecture.clone();
|
||||||
|
let __t_extract = std::time::Instant::now();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
extract_linked_natives(
|
extract_linked_natives(
|
||||||
&direct,
|
&direct,
|
||||||
@ -2108,6 +2124,7 @@ pub async fn launch_minecraft(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await??;
|
.await??;
|
||||||
|
tracing::info!("[launch-timing] extract_linked_natives: {}ms", __t_extract.elapsed().as_millis());
|
||||||
} else if direct_launch.is_none() {
|
} else if direct_launch.is_none() {
|
||||||
if offline_mode {
|
if offline_mode {
|
||||||
natives::prepare_native_libraries(
|
natives::prepare_native_libraries(
|
||||||
@ -2137,6 +2154,8 @@ 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!(
|
tracing::debug!(
|
||||||
"Found QuickPlayVersion for {}: {quick_play_version:?}",
|
"Found QuickPlayVersion for {}: {quick_play_version:?}",
|
||||||
content_set.game_version
|
content_set.game_version
|
||||||
@ -2399,7 +2418,7 @@ pub async fn launch_minecraft(
|
|||||||
let logs_folder = state.directories.game_logs_dir(&instance_path);
|
let logs_folder = state.directories.game_logs_dir(&instance_path);
|
||||||
// Create Minecraft child by inserting it into the state
|
// Create Minecraft child by inserting it into the state
|
||||||
// This also spawns the process and prepares the subsequent processes
|
// This also spawns the process and prepares the subsequent processes
|
||||||
state
|
let __launch_process = state
|
||||||
.process_manager
|
.process_manager
|
||||||
.insert_new_process(
|
.insert_new_process(
|
||||||
&instance.id,
|
&instance.id,
|
||||||
@ -2443,7 +2462,9 @@ pub async fn launch_minecraft(
|
|||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
|
tracing::info!("[launch-timing] process_spawn: {}ms", __lt.elapsed().as_millis());
|
||||||
|
__launch_process
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -630,7 +630,10 @@ impl std::io::Write for TruncatedConsoleWriter {
|
|||||||
// Handling for the live development logging
|
// Handling for the live development logging
|
||||||
// This will log to the console, and will not log to a file
|
// This will log to the console, and will not log to a file
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub fn start_logger(_app_identifier: &str) -> Option<()> {
|
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::*;
|
use tracing_subscriber::prelude::*;
|
||||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
@ -640,12 +643,39 @@ pub fn start_logger(_app_identifier: &str) -> Option<()> {
|
|||||||
.add_directive("hyper=info".parse().ok()?)
|
.add_directive("hyper=info".parse().ok()?)
|
||||||
.add_directive("hyper_util=info".parse().ok()?)
|
.add_directive("hyper_util=info".parse().ok()?)
|
||||||
.add_directive("sqlx=warn".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()
|
tracing_subscriber::registry()
|
||||||
.with(tracing_subscriber::fmt::layer().with_writer(|| {
|
.with(console_layer)
|
||||||
TruncatedConsoleWriter {
|
.with(file_layer)
|
||||||
stdout: std::io::stdout(),
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.with(filter)
|
.with(filter)
|
||||||
.with(tracing_error::ErrorLayer::default())
|
.with(tracing_error::ErrorLayer::default())
|
||||||
.init();
|
.init();
|
||||||
|
|||||||
Reference in New Issue
Block a user