forked from AxTps/Starlight_Lancher
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42b0c7e7af | |||
| 405d980311 | |||
| 905e905eca | |||
| cda7bb284a | |||
| ffe529df57 | |||
| 984f3ddbd6 | |||
| a477b1fb9a | |||
| a47d309103 | |||
| 392f01ea4b | |||
| 5006f432dc | |||
| 51fb0c30f7 |
11
.github/workflows/axolotl-release.yml
vendored
11
.github/workflows/axolotl-release.yml
vendored
@ -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
131
README.md
@ -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-Server(SLS)**、面向 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.8s(230 mod 的 NeoForge 整合包),
|
||||
属整合包固有成本,任何启动器无法缩短。
|
||||
- 曾尝试开启 NeoForge 早期窗口(`fml.toml` 的 `earlyWindowControl`),
|
||||
该整合包下与 mod 冲突导致更慢(11s → 30s),**已回滚**。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
# debug(含计时埋点、文件日志)
|
||||
cargo build -p theseus_gui
|
||||
|
||||
# release
|
||||
cargo build --release -p theseus_gui
|
||||
```
|
||||
---
|
||||
|
||||
@ -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 |
@ -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"
|
||||
|
||||
BIN
apps/app-frontend/src/assets/illustrations/instance-thinking.png
Normal file
BIN
apps/app-frontend/src/assets/illustrations/instance-thinking.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 740 KiB |
161
apps/app-frontend/src/components/instance/HostedGameDirModal.vue
Normal file
161
apps/app-frontend/src/components/instance/HostedGameDirModal.vue
Normal 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>
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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' },
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 ?? []) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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> }> = []
|
||||
|
||||
@ -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.')
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
124
apps/app-frontend/src/helpers/hosted-install-retry.test.ts
Normal file
124
apps/app-frontend/src/helpers/hosted-install-retry.test.ts
Normal 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)
|
||||
})
|
||||
34
apps/app-frontend/src/helpers/hosted-install-retry.ts
Normal file
34
apps/app-frontend/src/helpers/hosted-install-retry.ts
Normal 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)
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
164
apps/app-frontend/src/helpers/instance-player.test.ts
Normal file
164
apps/app-frontend/src/helpers/instance-player.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
162
apps/app-frontend/src/helpers/instance-player.ts
Normal file
162
apps/app-frontend/src/helpers/instance-player.ts
Normal 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
|
||||
}
|
||||
@ -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', {
|
||||
|
||||
@ -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 ??=
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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": "这个皮肤站账号暂时没有可用的玩家角色。"
|
||||
|
||||
@ -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": "這個皮膚站帳號暫時沒有可用的玩家角色。"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -92,7 +92,6 @@ features = [
|
||||
"Foundation",
|
||||
"UI_ViewManagement",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
"url": "https://api.purpurmc.org/*"
|
||||
},
|
||||
{
|
||||
"url": "https://update.axlmc.org/*"
|
||||
"url": "https://skin.starlight.cool/*"
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8000/*"
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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...");
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
32
apps/app/src/skin_editor_bridge.js
Normal file
32
apps/app/src/skin_editor_bridge.js
Normal 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))
|
||||
})
|
||||
})()
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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'",
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN mc_force_unicode_font INTEGER NOT NULL DEFAULT FALSE;
|
||||
@ -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(|| {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)?;
|
||||
|
||||
228
packages/app-lib/src/api/pack/hosted/transport.rs
Normal file
228
packages/app-lib/src/api/pack/hosted/transport.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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('@'));
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -611,6 +611,7 @@ where
|
||||
}
|
||||
|
||||
let launch_overrides = InstanceLaunchOverrides {
|
||||
player: None,
|
||||
instance_mode: None,
|
||||
instance_id: instance_id.clone(),
|
||||
java_path: input.java_path,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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(¤t_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,
|
||||
¤t_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(¤t_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,
|
||||
×tamp,
|
||||
message,
|
||||
).await {
|
||||
tracing::error!("Failed to handle server join logging: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Self::emit_log4j_event(
|
||||
instance_id,
|
||||
¤t_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(¤t_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,
|
||||
×tamp,
|
||||
message,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to handle server join logging: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Self::emit_log4j_event(instance_id, ¤t_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::*;
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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
775
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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`)
|
||||
|
||||
70
scripts/axolotl/create-update-manifest.test.mjs
Normal file
70
scripts/axolotl/create-update-manifest.test.mjs
Normal 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 })
|
||||
}
|
||||
49
scripts/axolotl/skin-editor-bridge.test.mjs
Normal file
49
scripts/axolotl/skin-editor-bridge.test.mjs
Normal 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)
|
||||
})
|
||||
@ -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}`)
|
||||
|
||||
@ -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 个文件,复用现有下载重试、连接管理与完整性校验。独立弹窗逐个显示文件字节进度,下载页同时显示这些任务;所有文件成功下载、替换后才允许启动,任一失败则保留错误并阻止启动。再次尝试会清理上一轮弹窗进度和失败任务。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user